import ChatAPIService import Combine import Foundation import GitHubCopilotService import Preferences import ConversationServiceProvider import BuiltinExtension import JSONRPC import Status import Persist import PersistMiddleware import ChatTab import Logger import Workspace import XcodeInspector import OrderedCollections import SystemUtils public protocol ChatServiceType { var memory: ContextAwareAutoManagedChatMemory { get set } func send(_ id: String, content: String, contentImages: [ChatCompletionContentPartImage], contentImageReferences: [ImageReference], skillSet: [ConversationSkill], references: [FileReference], model: String?, agentMode: Bool, userLanguage: String?, turnId: String?) async throws func stopReceivingMessage() async func upvote(_ id: String, _ rating: ConversationRating) async func downvote(_ id: String, _ rating: ConversationRating) async func copyCode(_ id: String) async } struct ToolCallRequest { let requestId: JSONId let turnId: String let roundId: Int let toolCallId: String let completion: (AnyJSONRPCResponse) -> Void } public struct FileEdit: Equatable { public enum Status: String { case none = "none" case kept = "kept" case undone = "undone" } public let fileURL: URL public let originalContent: String public var modifiedContent: String public var status: Status /// Different toolName, the different undo logic. Like `insert_edit_into_file` and `create_file` public var toolName: ToolName public init( fileURL: URL, originalContent: String, modifiedContent: String, status: Status = .none, toolName: ToolName ) { self.fileURL = fileURL self.originalContent = originalContent self.modifiedContent = modifiedContent self.status = status self.toolName = toolName } } public final class ChatService: ChatServiceType, ObservableObject { public var memory: ContextAwareAutoManagedChatMemory @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false @Published public internal(set) var fileEditMap: OrderedDictionary = [:] public let chatTabInfo: ChatTabInfo private let conversationProvider: ConversationServiceProvider? private let conversationProgressHandler: ConversationProgressHandler private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared // sync all the files in the workspace to watch for changes. private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared private var cancellables = Set() private var activeRequestId: String? private(set) public var conversationId: String? private var skillSet: [ConversationSkill] = [] private var lastUserRequest: ConversationRequest? private var isRestored: Bool = false private var pendingToolCallRequests: [String: ToolCallRequest] = [:] init(provider: any ConversationServiceProvider, memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(), conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared, chatTabInfo: ChatTabInfo) { self.memory = memory self.conversationProvider = provider self.conversationProgressHandler = conversationProgressHandler self.chatTabInfo = chatTabInfo memory.chatService = self subscribeToNotifications() subscribeToConversationContextRequest() subscribeToWatchedFilesHandler() subscribeToClientToolInvokeEvent() subscribeToClientToolConfirmationEvent() } deinit { Task { [weak self] in await self?.stopReceivingMessage() } // Clear all subscriptions cancellables.forEach { $0.cancel() } cancellables.removeAll() // Memory will be deallocated automatically } private func subscribeToNotifications() { memory.observeHistoryChange { [weak self] in Task { [weak self] in guard let memory = self?.memory else { return } self?.chatHistory = await memory.history } } conversationProgressHandler.onBegin.sink { [weak self] (token, progress) in self?.handleProgressBegin(token: token, progress: progress) }.store(in: &cancellables) conversationProgressHandler.onProgress.sink { [weak self] (token, progress) in self?.handleProgressReport(token: token, progress: progress) }.store(in: &cancellables) conversationProgressHandler.onEnd.sink { [weak self] (token, progress) in self?.handleProgressEnd(token: token, progress: progress) }.store(in: &cancellables) } private func subscribeToConversationContextRequest() { self.conversationContextHandler.onConversationContext.sink(receiveValue: { [weak self] (request, completion) in guard let skills = self?.skillSet, !skills.isEmpty, request.params!.conversationId == self?.conversationId else { return } skills.forEach { skill in if (skill.applies(params: request.params!)) { skill.resolveSkill(request: request, completion: completion) } } }).store(in: &cancellables) } private func subscribeToWatchedFilesHandler() { self.watchedFilesHandler.onWatchedFiles.sink(receiveValue: { [weak self] (request, completion) in guard let self, request.params!.workspaceFolder.uri != "/" else { return } self.startFileChangeWatcher() }).store(in: &cancellables) } private func subscribeToClientToolConfirmationEvent() { ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in guard let params = request.params, params.conversationId == self?.conversationId else { return } let editAgentRounds: [AgentRound] = [ AgentRound(roundId: params.roundId, reply: "", toolCalls: [ AgentToolCall(id: params.toolCallId, name: params.name, status: .waitForConfirmation, invokeParams: params) ] ) ] self?.appendToolCallHistory(turnId: params.turnId, editAgentRounds: editAgentRounds) self?.pendingToolCallRequests[params.toolCallId] = ToolCallRequest( requestId: request.id, turnId: params.turnId, roundId: params.roundId, toolCallId: params.toolCallId, completion: completion) }).store(in: &cancellables) } private func subscribeToClientToolInvokeEvent() { ClientToolHandlerImpl.shared.onClientToolInvokeEvent.sink(receiveValue: { [weak self] (request, completion) in guard let params = request.params, params.conversationId == self?.conversationId else { return } guard let copilotTool = CopilotToolRegistry.shared.getTool(name: params.name) else { completion(AnyJSONRPCResponse(id: request.id, result: JSONValue.array([ JSONValue.null, JSONValue.hash( [ "code": .number(-32601), "message": .string("Tool function not found") ]) ]) ) ) return } copilotTool.invokeTool(request, completion: completion, chatHistoryUpdater: self?.appendToolCallHistory, contextProvider: self) }).store(in: &cancellables) } private func appendToolCallHistory(turnId: String, editAgentRounds: [AgentRound]) { let chatTabId = self.chatTabInfo.id Task { let message = ChatMessage( id: turnId, chatTabID: chatTabId, clsTurnID: turnId, role: .assistant, content: "", references: [], steps: [], editAgentRounds: editAgentRounds ) await self.memory.appendMessage(message) } } public func updateFileEdits(by fileEdit: FileEdit) { if let existingFileEdit = self.fileEditMap[fileEdit.fileURL] { self.fileEditMap[fileEdit.fileURL] = .init( fileURL: fileEdit.fileURL, originalContent: existingFileEdit.originalContent, modifiedContent: fileEdit.modifiedContent, toolName: existingFileEdit.toolName ) } else { self.fileEditMap[fileEdit.fileURL] = fileEdit } } public static func service(for chatTabInfo: ChatTabInfo) -> ChatService { let provider = BuiltinExtensionConversationServiceProvider( extension: GitHubCopilotExtension.self ) return ChatService(provider: provider, chatTabInfo: chatTabInfo) } // this will be triggerred in conversation tab if needed public func restoreIfNeeded() { guard self.isRestored == false else { return } Task { let storedChatMessages = fetchAllChatMessagesFromStorage() await mutateHistory { history in history.append(contentsOf: storedChatMessages) } } self.isRestored = true } public func updateToolCallStatus(toolCallId: String, status: AgentToolCall.ToolCallStatus, payload: Any? = nil) { if status == .cancelled { resetOngoingRequest() return } // Send the tool call result back to the server if let toolCallRequest = self.pendingToolCallRequests[toolCallId], status == .accepted { self.pendingToolCallRequests.removeValue(forKey: toolCallId) let toolResult = LanguageModelToolConfirmationResult(result: .Accept) let jsonResult = try? JSONEncoder().encode(toolResult) let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null toolCallRequest.completion( AnyJSONRPCResponse( id: toolCallRequest.requestId, result: JSONValue.array([ jsonValue, JSONValue.null ]) ) ) } // Update the tool call status in the chat history Task { guard let lastMessage = await memory.history.last, lastMessage.role == .assistant else { return } var updatedAgentRounds: [AgentRound] = [] for i in 0.. = [], contentImageReferences: Array = [], skillSet: Array, references: Array, model: String? = nil, agentMode: Bool = false, userLanguage: String? = nil, turnId: String? = nil ) async throws { guard activeRequestId == nil else { return } let workDoneToken = UUID().uuidString activeRequestId = workDoneToken let finalImageReferences: [ImageReference] let finalContentImages: [ChatCompletionContentPartImage] if !contentImageReferences.isEmpty { // User attached images are all parsed as ImageReference finalImageReferences = contentImageReferences finalContentImages = contentImageReferences .map { ChatCompletionContentPartImage( url: $0.dataURL(imageType: $0.source == .screenshot ? "png" : "") ) } } else { // In current implementation, only resend message will have contentImageReferences // No need to convert ChatCompletionContentPartImage to ImageReference for persistence finalImageReferences = [] finalContentImages = contentImages } var chatMessage = ChatMessage( id: id, chatTabID: self.chatTabInfo.id, role: .user, content: content, contentImageReferences: finalImageReferences, references: references.toConversationReferences() ) let currentEditorSkill = skillSet.first(where: { $0.id == CurrentEditorSkill.ID }) as? CurrentEditorSkill let currentFileReadability = currentEditorSkill == nil ? nil : FileUtils.checkFileReadability(at: currentEditorSkill!.currentFilePath) var errorMessage: ChatMessage? var currentTurnId: String? = turnId // If turnId is provided, it is used to update the existing message, no need to append the user message if turnId == nil { if let currentFileReadability, !currentFileReadability.isReadable { // For associating error message with user message currentTurnId = UUID().uuidString chatMessage.clsTurnID = currentTurnId errorMessage = buildErrorMessage( turnId: currentTurnId!, errorMessages: [ currentFileReadability.errorMessage( using: CurrentEditorSkill.readabilityErrorMessageProvider ) ].compactMap { $0 }.filter { !$0.isEmpty } ) } await memory.appendMessage(chatMessage) } // reset file edits self.resetFileEdits() // persist saveChatMessageToStorage(chatMessage) if content.hasPrefix("/releaseNotes") { if let fileURL = Bundle.main.url(forResource: "ReleaseNotes", withExtension: "md"), let whatsNewContent = try? String(contentsOf: fileURL) { // will be persist in resetOngoingRequest() // there is no turn id from CLS, just set it as id let clsTurnID = UUID().uuidString let progressMessage = ChatMessage( id: clsTurnID, chatTabID: self.chatTabInfo.id, clsTurnID: clsTurnID, role: .assistant, content: whatsNewContent, references: [] ) await memory.appendMessage(progressMessage) } resetOngoingRequest() return } if let errorMessage { Task { await memory.appendMessage(errorMessage) } } var activeDoc: Doc? var validSkillSet: [ConversationSkill] = skillSet if let currentEditorSkill, currentFileReadability?.isReadable == true { activeDoc = Doc(uri: currentEditorSkill.currentFile.url.absoluteString) } else { validSkillSet.removeAll(where: { $0.id == CurrentEditorSkill.ID || $0.id == ProblemsInActiveDocumentSkill.ID }) } let request = createConversationRequest( workDoneToken: workDoneToken, content: content, contentImages: finalContentImages, activeDoc: activeDoc, references: references, model: model, agentMode: agentMode, userLanguage: userLanguage, turnId: currentTurnId, skillSet: validSkillSet ) self.lastUserRequest = request self.skillSet = validSkillSet try await sendConversationRequest(request) } private func createConversationRequest( workDoneToken: String, content: String, contentImages: [ChatCompletionContentPartImage] = [], activeDoc: Doc?, references: [FileReference], model: String? = nil, agentMode: Bool = false, userLanguage: String? = nil, turnId: String? = nil, skillSet: [ConversationSkill] ) -> ConversationRequest { let skillCapabilities: [String] = [CurrentEditorSkill.ID, ProblemsInActiveDocumentSkill.ID] let supportedSkills: [String] = skillSet.map { $0.id } let ignoredSkills: [String] = skillCapabilities.filter { !supportedSkills.contains($0) } /// replace the `@workspace` to `@project` let newContent = replaceFirstWord(in: content, from: "@workspace", to: "@project") return ConversationRequest( workDoneToken: workDoneToken, content: newContent, contentImages: contentImages, workspaceFolder: "", activeDoc: activeDoc, skills: skillCapabilities, ignoredSkills: ignoredSkills, references: references, model: model, agentMode: agentMode, userLanguage: userLanguage, turnId: turnId ) } public func sendAndWait(_ id: String, content: String) async throws -> String { try await send(id, content: content, skillSet: [], references: []) if let reply = await memory.history.last(where: { $0.role == .assistant })?.content { return reply } return "" } public func stopReceivingMessage() async { if let activeRequestId = activeRequestId { do { try await conversationProvider?.stopReceivingMessage(activeRequestId, workspaceURL: getWorkspaceURL()) } catch { print("Failed to cancel ongoing request with WDT: \(activeRequestId)") } } resetOngoingRequest() } public func clearHistory() async { let messageIds = await memory.history.map { $0.id } await memory.clearHistory() if let activeRequestId = activeRequestId { do { try await conversationProvider?.stopReceivingMessage(activeRequestId, workspaceURL: getWorkspaceURL()) } catch { print("Failed to cancel ongoing request with WDT: \(activeRequestId)") } } deleteAllChatMessagesFromStorage(messageIds) resetOngoingRequest() } public func deleteMessage(id: String) async { await memory.removeMessage(id) deleteChatMessageFromStorage(id) } public func resendMessage(id: String, model: String? = nil) async throws { if let _ = (await memory.history).first(where: { $0.id == id }), let lastUserRequest { // TODO: clean up contents for resend message activeRequestId = nil try await send( id, content: lastUserRequest.content, contentImages: lastUserRequest.contentImages, skillSet: skillSet, references: lastUserRequest.references ?? [], model: model != nil ? model : lastUserRequest.model, agentMode: lastUserRequest.agentMode, userLanguage: lastUserRequest.userLanguage, turnId: id ) } } public func setMessageAsExtraPrompt(id: String) async { if let message = (await memory.history).first(where: { $0.id == id }) { await mutateHistory { history in let chatMessage: ChatMessage = .init( chatTabID: self.chatTabInfo.id, role: .assistant, content: message.content ) history.append(chatMessage) self.saveChatMessageToStorage(chatMessage) } } } public func mutateHistory(_ mutator: @escaping (inout [ChatMessage]) -> Void) async { await memory.mutateHistory(mutator) } public func handleCustomCommand(_ command: CustomCommand) async throws { struct CustomCommandInfo { var specifiedSystemPrompt: String? var extraSystemPrompt: String? var sendingMessageImmediately: String? var name: String? } let info: CustomCommandInfo? = { switch command.feature { case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): let updatePrompt = useExtraSystemPrompt ?? true return .init( extraSystemPrompt: updatePrompt ? extraSystemPrompt : nil, sendingMessageImmediately: prompt, name: command.name ) case let .customChat(systemPrompt, prompt): return .init( specifiedSystemPrompt: systemPrompt, extraSystemPrompt: "", sendingMessageImmediately: prompt, name: command.name ) case .promptToCode: return nil case .singleRoundDialog: return nil } }() guard let info else { return } let templateProcessor = CustomCommandTemplateProcessor() if info.specifiedSystemPrompt != nil || info.extraSystemPrompt != nil { await mutateHistory { history in let chatMessage: ChatMessage = .init( chatTabID: self.chatTabInfo.id, role: .assistant, content: "" ) history.append(chatMessage) self.saveChatMessageToStorage(chatMessage) } } if let sendingMessageImmediately = info.sendingMessageImmediately, !sendingMessageImmediately.isEmpty { try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately), skillSet: [], references: []) } } public func getWorkspaceURL() -> URL? { guard !chatTabInfo.workspacePath.isEmpty else { return nil } return URL(fileURLWithPath: chatTabInfo.workspacePath) } public func upvote(_ id: String, _ rating: ConversationRating) async { try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL()) } public func downvote(_ id: String, _ rating: ConversationRating) async { try? await conversationProvider?.rateConversation(turnId: id, rating: rating, workspaceURL: getWorkspaceURL()) } public func copyCode(_ id: String) async { // TODO: pass copy code info to Copilot server } // not used public func handleSingleRoundDialogCommand( systemPrompt: String?, overwriteSystemPrompt: Bool, prompt: String ) async throws -> String { let templateProcessor = CustomCommandTemplateProcessor() return try await sendAndWait(UUID().uuidString, content: templateProcessor.process(prompt)) } private func handleProgressBegin(token: String, progress: ConversationProgressBegin) { guard let workDoneToken = activeRequestId, workDoneToken == token else { return } conversationId = progress.conversationId let turnId = progress.turnId Task { if var lastUserMessage = await memory.history.last(where: { $0.role == .user }) { // Case: New conversation where error message was generated before CLS request // Using clsTurnId to associate this error message with the corresponding user message // When merging error messages with bot responses from CLS, these properties need to be updated await memory.mutateHistory { history in if let existingBotIndex = history.lastIndex(where: { $0.role == .assistant && $0.clsTurnID == lastUserMessage.clsTurnID }) { history[existingBotIndex].id = turnId history[existingBotIndex].clsTurnID = turnId } } lastUserMessage.clsTurnID = progress.turnId saveChatMessageToStorage(lastUserMessage) } /// Display an initial assistant message immediately after the user sends a message. /// This improves perceived responsiveness, especially in Agent Mode where the first /// ProgressReport may take long time. let message = ChatMessage( id: turnId, chatTabID: self.chatTabInfo.id, clsTurnID: turnId, role: .assistant, content: "" ) // will persist in resetOngoingRequest() await memory.appendMessage(message) } } private func handleProgressReport(token: String, progress: ConversationProgressReport) { guard let workDownToken = activeRequestId, workDownToken == token else { return } let id = progress.turnId var content = "" var references: [ConversationReference] = [] var steps: [ConversationProgressStep] = [] var editAgentRounds: [AgentRound] = [] if let reply = progress.reply { content = reply } if let progressReferences = progress.references, !progressReferences.isEmpty { references = progressReferences.toConversationReferences() } if let progressSteps = progress.steps, !progressSteps.isEmpty { steps = progressSteps } if let progressAgentRounds = progress.editAgentRounds, !progressAgentRounds.isEmpty { editAgentRounds = progressAgentRounds } if content.isEmpty && references.isEmpty && steps.isEmpty && editAgentRounds.isEmpty { return } // create immutable copies let messageContent = content let messageReferences = references let messageSteps = steps let messageAgentRounds = editAgentRounds Task { let message = ChatMessage( id: id, chatTabID: self.chatTabInfo.id, clsTurnID: id, role: .assistant, content: messageContent, references: messageReferences, steps: messageSteps, editAgentRounds: messageAgentRounds ) // will persist in resetOngoingRequest() await memory.appendMessage(message) } } private func handleProgressEnd(token: String, progress: ConversationProgressEnd) { guard let workDoneToken = activeRequestId, workDoneToken == token else { return } let followUp = progress.followUp if let CLSError = progress.error { // CLS Error Code 402: reached monthly chat messages limit if CLSError.code == 402 { Task { await Status.shared .updateCLSStatus(.warning, busy: false, message: CLSError.message) let errorMessage = buildErrorMessage( turnId: progress.turnId, panelMessages: [.init(type: .error, title: String(CLSError.code ?? 0), message: CLSError.message, location: .Panel)]) // will persist in resetongoingRequest() await memory.appendMessage(errorMessage) if let lastUserRequest, let currentUserPlan = await Status.shared.currentUserPlan(), currentUserPlan != "free" { guard let fallbackModel = CopilotModelManager.getFallbackLLM( scope: lastUserRequest.agentMode ? .agentPanel : .chatPanel ) else { resetOngoingRequest() return } do { CopilotModelManager.switchToFallbackModel() try await resendMessage(id: progress.turnId, model: fallbackModel.id) } catch { Logger.gitHubCopilot.error(error) resetOngoingRequest() } return } } } else if CLSError.code == 400 && CLSError.message.contains("model is not supported") { Task { let errorMessage = buildErrorMessage( turnId: progress.turnId, errorMessages: ["Oops, the model is not supported. Please enable it first in [GitHub Copilot settings](https://github.com/settings/copilot)."] ) await memory.appendMessage(errorMessage) resetOngoingRequest() return } } else { Task { let errorMessage = buildErrorMessage(turnId: progress.turnId, errorMessages: [CLSError.message]) // will persist in resetOngoingRequest() await memory.appendMessage(errorMessage) resetOngoingRequest() return } } } Task { let message = ChatMessage( id: progress.turnId, chatTabID: self.chatTabInfo.id, clsTurnID: progress.turnId, role: .assistant, content: "", followUp: followUp, suggestedTitle: progress.suggestedTitle ) // will persist in resetOngoingRequest() await memory.appendMessage(message) resetOngoingRequest() } } private func buildErrorMessage( turnId: String, errorMessages: [String] = [], panelMessages: [CopilotShowMessageParams] = [] ) -> ChatMessage { return .init( id: turnId, chatTabID: chatTabInfo.id, clsTurnID: turnId, role: .assistant, content: "", errorMessages: errorMessages, panelMessages: panelMessages ) } private func resetOngoingRequest() { activeRequestId = nil isReceivingMessage = false // cancel all pending tool call requests for (_, request) in pendingToolCallRequests { pendingToolCallRequests.removeValue(forKey: request.toolCallId) let toolResult = LanguageModelToolConfirmationResult(result: .Dismiss) let jsonResult = try? JSONEncoder().encode(toolResult) let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null request.completion( AnyJSONRPCResponse( id: request.requestId, result: JSONValue.array([ jsonValue, JSONValue.null ]) ) ) } Task { // mark running steps to cancelled await mutateHistory({ history in guard !history.isEmpty, let lastIndex = history.indices.last, history[lastIndex].role == .assistant else { return } for i in 0.. 0 { // invoke history turns let turns = chatHistory.toTurns() requestWithTurns.turns = turns } try await conversationProvider?.createConversation(requestWithTurns, workspaceURL: getWorkspaceURL()) } } catch { resetOngoingRequest() throw error } } // MARK: - File Edit public func undoFileEdit(for fileURL: URL) throws { guard let fileEdit = self.fileEditMap[fileURL], fileEdit.status == .none else { return } switch fileEdit.toolName { case .insertEditIntoFile: InsertEditIntoFileTool.applyEdit(for: fileURL, content: fileEdit.originalContent, contextProvider: self) case .createFile: try CreateFileTool.undo(for: fileURL) default: return } self.fileEditMap[fileURL]!.status = .undone } public func keepFileEdit(for fileURL: URL) { guard let fileEdit = self.fileEditMap[fileURL], fileEdit.status == .none else { return } self.fileEditMap[fileURL]!.status = .kept } public func resetFileEdits() { self.fileEditMap = [:] } public func discardFileEdit(for fileURL: URL) throws { try self.undoFileEdit(for: fileURL) self.fileEditMap.removeValue(forKey: fileURL) } } public final class SharedChatService { public var chatTemplates: [ChatTemplate]? = nil public var chatAgents: [ChatAgent]? = nil private let conversationProvider: ConversationServiceProvider? public static let shared = SharedChatService.service() init(provider: any ConversationServiceProvider) { self.conversationProvider = provider } public static func service() -> SharedChatService { let provider = BuiltinExtensionConversationServiceProvider( extension: GitHubCopilotExtension.self ) return SharedChatService(provider: provider) } public func loadChatTemplates() async -> [ChatTemplate]? { guard self.chatTemplates == nil else { return self.chatTemplates } do { if let templates = (try await conversationProvider?.templates()) { self.chatTemplates = templates return templates } } catch { // handle error if desired } return nil } public func copilotModels() async -> [CopilotModel] { guard let models = try? await conversationProvider?.models() else { return [] } return models } public func loadChatAgents() async -> [ChatAgent]? { guard self.chatAgents == nil else { return self.chatAgents } do { if let chatAgents = (try await conversationProvider?.agents()) { self.chatAgents = chatAgents return chatAgents } } catch { // handle error if desired } return nil } } extension ChatService { // do storage operatoin in the background private func runInBackground(_ operation: @escaping () -> Void) { Task.detached(priority: .utility) { operation() } } func saveChatMessageToStorage(_ message: ChatMessage) { runInBackground { ChatMessageStore.save(message, with: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username)) } } func deleteChatMessageFromStorage(_ id: String) { runInBackground { ChatMessageStore.delete(by: id, with: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username)) } } func deleteAllChatMessagesFromStorage(_ ids: [String]) { runInBackground { ChatMessageStore.deleteAll(by: ids, with: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username)) } } func fetchAllChatMessagesFromStorage() -> [ChatMessage] { return ChatMessageStore.getAll(by: self.chatTabInfo.id, metadata: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username)) } /// for file change watcher func startFileChangeWatcher() { Task { [weak self] in guard let self else { return } let workspaceURL = URL(fileURLWithPath: self.chatTabInfo.workspacePath) let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) ?? workspaceURL await FileChangeWatcherServicePool.shared.watch( for: workspaceURL ) { fileEvents in Task { [weak self] in guard let self else { return } try? await self.conversationProvider?.notifyDidChangeWatchedFiles( .init(workspaceUri: projectURL.path, changes: fileEvents), workspace: .init(workspaceURL: workspaceURL, projectURL: projectURL) ) } } } } } func replaceFirstWord(in content: String, from oldWord: String, to newWord: String) -> String { let pattern = "^\(oldWord)\\b" if let regex = try? NSRegularExpression(pattern: pattern, options: []) { let range = NSRange(location: 0, length: content.utf16.count) return regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: newWord) } return content } extension Array where Element == Reference { func toConversationReferences() -> [ConversationReference] { return self.map { .init(uri: $0.uri, status: .included, kind: .reference($0)) } } } extension Array where Element == FileReference { func toConversationReferences() -> [ConversationReference] { return self.map { .init(uri: $0.url.path, status: .included, kind: .fileReference($0)) } } } extension [ChatMessage] { // transfer chat messages to turns // used to restore chat history for CLS func toTurns() -> [TurnSchema] { var turns: [TurnSchema] = [] let count = self.count var index = 0 while index < count { let message = self[index] if case .user = message.role { var turn = TurnSchema(request: message.content, turnId: message.clsTurnID) // has next message if index + 1 < count { let nextMessage = self[index + 1] if nextMessage.role == .assistant { turn.response = nextMessage.content + extractContentFromEditAgentRounds(nextMessage.editAgentRounds) index += 1 } } turns.append(turn) } index += 1 } return turns } private func extractContentFromEditAgentRounds(_ editAgentRounds: [AgentRound]) -> String { var content = "" for round in editAgentRounds { if !round.reply.isEmpty { content += round.reply } } return content } }