From c81625aff89132f290ed7989ab789e3f98b4e9c7 Mon Sep 17 00:00:00 2001 From: Kellyvv Date: Mon, 6 Apr 2026 16:05:56 +0800 Subject: [PATCH] Stabilize local skills and keep shell install default --- Agent/AgentEngine.swift | 457 +++++++++++++++++++++++++++++++++-- LLM/MLXLocalLLMService.swift | 355 +++++++++++++++++++++++++-- LLM/PromptBuilder.swift | 48 ++++ Skills/SkillLoader.swift | 73 ++++-- Skills/ToolRegistry.swift | 430 +++++++++++++++++++++++++++++++- UI/ConfigurationsView.swift | 185 +++++++++++--- 6 files changed, 1451 insertions(+), 97 deletions(-) diff --git a/Agent/AgentEngine.swift b/Agent/AgentEngine.swift index e1e4d45..edf9f6c 100644 --- a/Agent/AgentEngine.swift +++ b/Agent/AgentEngine.swift @@ -174,7 +174,8 @@ class AgentEngine { // MARK: - Skill 查找(文件驱动) private func findSkillId(for name: String) -> String? { - if skillLoader.getDefinition(name) != nil { return name } + let resolvedName = skillLoader.canonicalSkillId(for: name) + if skillLoader.getDefinition(resolvedName) != nil { return resolvedName } return skillLoader.findSkillId(forTool: name) } @@ -187,11 +188,42 @@ class AgentEngine { } private func handleLoadSkill(skillName: String) -> String? { - guard let entry = skillEntries.first(where: { $0.id == skillName }), + let resolvedSkillName = skillLoader.canonicalSkillId(for: skillName) + guard let entry = skillEntries.first(where: { $0.id == resolvedSkillName }), entry.isEnabled else { return nil } - return skillLoader.loadBody(skillId: skillName) + return skillLoader.loadBody(skillId: resolvedSkillName) + } + + private func canonicalToolName(_ toolName: String, arguments: [String: Any]) -> String { + switch toolName { + case "contacts": + if arguments["action"] as? String == "delete" + || arguments["delete"] as? Bool == true { + return "contacts-delete" + } + if arguments["phone"] != nil + || arguments["company"] != nil + || arguments["notes"] != nil { + return "contacts-upsert" + } + if arguments["identifier"] != nil + || arguments["name"] != nil + || arguments["email"] != nil + || arguments["query"] != nil { + return "contacts-search" + } + return "contacts-search" + case "contacts_delete", "contacts-delete-contact": + return "contacts-delete" + case "contacts_upsert": + return "contacts-upsert" + case "contacts_search": + return "contacts-search" + default: + return toolName + } } private func handleToolExecution(toolName: String, args: [String: Any]) async throws -> String { @@ -275,14 +307,59 @@ class AgentEngine { } else { candidateNames = ["device-info"] } + case "contacts": + let searchKeywords = [ + "查", "检查", "查询", "看看", "电话", "手机号", "号码", + "联系方式", "邮箱", "mail", "email" + ] + let deleteKeywords = [ + "删除", "删掉", "删了", "删吗", "删", "移除", "去掉", "清除" + ] + let upsertKeywords = [ + "存", "保存", "添加", "新建", "创建", "记一下", "记住", "更新", "修改" + ] + + if deleteKeywords.contains(where: has) { + candidateNames = ["contacts-delete"] + } else if upsertKeywords.contains(where: has) { + candidateNames = ["contacts-upsert"] + } else if searchKeywords.contains(where: has) { + candidateNames = ["contacts-search"] + } else { + candidateNames = ["contacts-upsert", "contacts-search", "contacts-delete"] + } + case "clipboard": + let writeKeywords = [ + "复制", "拷贝", "写入", "放到剪贴板", "存到剪贴板", "复制到剪贴板" + ] + let readKeywords = [ + "剪贴板", "读一下", "读取", "看看", "看下", "查看", "内容" + ] + + if writeKeywords.contains(where: has), + let arguments = heuristicArgumentsForTool(toolName: "clipboard-write", userQuestion: userQuestion), + validateSingleToolArguments(toolName: "clipboard-write", arguments: arguments) { + return ("clipboard-write", arguments) + } + + if readKeywords.contains(where: has) { + candidateNames = ["clipboard-read"] + } else { + candidateNames = ["clipboard-read", "clipboard-write"] + } default: candidateNames = [] } for name in candidateNames { - if let tool = tools.first(where: { $0.name == name }), tool.parameters == "无" { + guard let tool = tools.first(where: { $0.name == name }) else { continue } + if tool.parameters == "无" { return (tool.name, [:]) } + if let arguments = heuristicArgumentsForTool(toolName: tool.name, userQuestion: userQuestion), + validateSingleToolArguments(toolName: tool.name, arguments: arguments) { + return (tool.name, arguments) + } } return nil @@ -498,13 +575,60 @@ class AgentEngine { guard !text.isEmpty else { return nil } switch toolName { + case "clipboard-write": + let patterns = [ + "(?:复制|拷贝|写入)(.+?)(?:到|进|到系统)?(?:剪贴板)", + "(?:把|将)(.+?)(?:复制|拷贝|写入)(?:到|进)?(?:剪贴板)" + ] + + var extracted: String? + for pattern in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } + let range = NSRange(text.startIndex..., in: text) + if let match = regex.firstMatch(in: text, range: range), + let capture = Range(match.range(at: 1), in: text) { + let value = String(text[capture]) + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "“”\"' ")) + if !value.isEmpty { + extracted = value + break + } + } + } + + if extracted == nil { + let cleaned = text + .replacingOccurrences(of: "帮我", with: "") + .replacingOccurrences(of: "把", with: "") + .replacingOccurrences(of: "将", with: "") + .replacingOccurrences(of: "复制到剪贴板", with: "") + .replacingOccurrences(of: "拷贝到剪贴板", with: "") + .replacingOccurrences(of: "写入剪贴板", with: "") + .replacingOccurrences(of: "放到剪贴板", with: "") + .replacingOccurrences(of: "存到剪贴板", with: "") + .replacingOccurrences(of: "复制", with: "") + .replacingOccurrences(of: "拷贝", with: "") + .replacingOccurrences(of: "写入", with: "") + .replacingOccurrences(of: "剪贴板", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "“”\"' ")) + if !cleaned.isEmpty { + extracted = cleaned + } + } + + guard let extracted, !extracted.isEmpty else { return nil } + return ["text": extracted] + case "contacts-upsert": let phone = text.firstMatch(of: /1[3-9]\d{9}/).map { String($0.0) } var name: String? let patterns = [ + "(?:把|将)?(.+?)(?:的)?(?:电话|手机号|号码|联系方式)\\s*(?:1[3-9]\\d{9})\\s*(?:添加到|加到|存到|保存到)?(?:联系人|通讯录)", "(?:帮我)?(?:存(?:一下)?|保存|记一下)(.+?)(?:的)?(?:电话|手机号|号码|联系方式)", - "(?:联系人|通讯录)里(?:添加|保存)?(.+?)(?:的)?(?:电话|手机号|号码|联系方式)" + "(?:联系人|通讯录)(?:里)?(?:添加|保存)?(.+?)(?:的)?(?:电话|手机号|号码|联系方式)" ] for pattern in patterns { guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } @@ -521,13 +645,24 @@ class AgentEngine { let phoneRange = text.range(of: phone) { let prefix = text[.. Bool { + switch toolName { + case "clipboard-read", "clipboard-write": + return true + default: + return false + } + } + + private func preflightSkillLoadCall(for userQuestion: String) -> String? { + let normalizedQuestion = userQuestion.lowercased() + + func has(_ keyword: String) -> Bool { + normalizedQuestion.contains(keyword) + } + + let mentionsClipboard = has("剪贴板") || has("clipboard") + guard mentionsClipboard else { return nil } + + let readKeywords = ["读取", "读一下", "看看", "看下", "查看", "内容", "是什么"] + let writeKeywords = ["复制", "拷贝", "写入", "放到剪贴板", "存到剪贴板", "复制到剪贴板"] + + if writeKeywords.contains(where: has), + let arguments = heuristicArgumentsForTool(toolName: "clipboard-write", userQuestion: userQuestion), + validateSingleToolArguments(toolName: "clipboard-write", arguments: arguments) { + return syntheticToolCallText( + name: "clipboard-write", + arguments: arguments + ) + } + + if readKeywords.contains(where: has) || mentionsClipboard { + return syntheticToolCallText( + name: "load_skill", + arguments: ["skill": "clipboard"] + ) + } + + return nil + } + + private func extractToolCallForLoadedSkills( originalPrompt: String, userQuestion: String, skillInstructions: String, skillIds: [String], images: [CIImage] ) async -> SingleToolExtractionOutcome { - guard let tool = singleRegisteredToolForLoadedSkills(skillIds: skillIds), - tool.parameters != "无" else { + let uniqueSkillIds = Array(NSOrderedSet(array: skillIds)) as? [String] ?? skillIds + guard uniqueSkillIds.count == 1, + let skillId = uniqueSkillIds.first else { return .failed } - let extractionPrompt = PromptBuilder.buildSingleToolArgumentsPrompt( + let tools = registeredTools(for: skillId) + .filter { $0.parameters != "无" } + guard !tools.isEmpty else { + return .failed + } + + if tools.count == 1, let tool = tools.first { + let extractionPrompt = PromptBuilder.buildSingleToolArgumentsPrompt( + originalPrompt: originalPrompt, + userQuestion: userQuestion, + skillInstructions: skillInstructions, + toolName: tool.name, + toolParameters: tool.parameters, + currentImageCount: images.count + ) + + if let raw = await streamLLM(prompt: extractionPrompt, images: images) { + let cleaned = cleanOutput(raw) + if let payload = parseJSONObject(cleaned) { + if let clarification = payload["_needs_clarification"] as? String, + !clarification.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty { + return .needsClarification(clarification) + } + + if validateSingleToolArguments(toolName: tool.name, arguments: payload) { + return .toolCall(name: tool.name, arguments: payload) + } + } + } + + if let heuristic = heuristicArgumentsForTool(toolName: tool.name, userQuestion: userQuestion), + validateSingleToolArguments(toolName: tool.name, arguments: heuristic) { + return .toolCall(name: tool.name, arguments: heuristic) + } + return .failed + } + + let allowedToolsSummary = tools.map { + "- \($0.name): \($0.description)\n 参数: \($0.parameters)" + }.joined(separator: "\n") + + let extractionPrompt = PromptBuilder.buildSkillToolSelectionPrompt( originalPrompt: originalPrompt, userQuestion: userQuestion, skillInstructions: skillInstructions, - toolName: tool.name, - toolParameters: tool.parameters, + allowedToolsSummary: allowedToolsSummary, currentImageCount: images.count ) @@ -658,15 +1030,21 @@ class AgentEngine { return .needsClarification(clarification) } - if validateSingleToolArguments(toolName: tool.name, arguments: payload) { - return .toolCall(name: tool.name, arguments: payload) + if let rawName = payload["name"] as? String, + let arguments = payload["arguments"] as? [String: Any] { + let toolName = canonicalToolName(rawName, arguments: arguments) + if tools.contains(where: { $0.name == toolName }), + validateSingleToolArguments(toolName: toolName, arguments: arguments) { + return .toolCall(name: toolName, arguments: arguments) + } } } } - if let heuristic = heuristicArgumentsForTool(toolName: tool.name, userQuestion: userQuestion), - validateSingleToolArguments(toolName: tool.name, arguments: heuristic) { - return .toolCall(name: tool.name, arguments: heuristic) + if let heuristic = inferFallbackToolCall(skillIds: skillIds, userQuestion: userQuestion), + tools.contains(where: { $0.name == heuristic.name }), + validateSingleToolArguments(toolName: heuristic.name, arguments: heuristic.arguments) { + return .toolCall(name: heuristic.name, arguments: heuristic.arguments) } return .failed @@ -965,6 +1343,16 @@ class AgentEngine { return parts.joined(separator: ",") + "。" } + case "contacts-search": + if let result = string("result") { + return result + } + + case "contacts-delete": + if let result = string("result") { + return result + } + default: break } @@ -983,6 +1371,7 @@ class AgentEngine { func setup() { applyModelSelection() + llm.refreshModelInstallStates() loadSystemPrompt() // 从 SYSPROMPT.md 注入 system prompt applySamplingConfig() llm.loadModel() @@ -1122,6 +1511,22 @@ class AgentEngine { historyDepth: historyDepth ) + if let preflightToolCall = preflightSkillLoadCall(for: normalizedText) { + log("[Agent] preflight tool path triggered") + if messages.indices.contains(msgIndex), + messages[msgIndex].role == .assistant, + messages[msgIndex].content == "▍" { + messages.remove(at: msgIndex) + } + await executeToolChain( + prompt: prompt, + fullText: preflightToolCall, + userQuestion: normalizedText, + images: promptImages + ) + return + } + var detectedToolCall = false var buffer = "" var bufferFlushed = false @@ -1262,7 +1667,7 @@ class AgentEngine { return } - guard let call = parseToolCall(fullText) else { + guard let parsedCall = parseToolCall(fullText) else { let cleaned = cleanOutput(fullText) if let lastAssistant = messages.lastIndex(where: { $0.role == .assistant }) { messages[lastAssistant].update(content: cleaned.isEmpty ? "(无回复)" : cleaned) @@ -1271,6 +1676,11 @@ class AgentEngine { return } + let call = ( + name: canonicalToolName(parsedCall.name, arguments: parsedCall.arguments), + arguments: parsedCall.arguments + ) + log("[Agent] Round \(round): tool_call name=\(call.name)") // ── load_skill ── @@ -1282,10 +1692,11 @@ class AgentEngine { var loadedDisplayNames: [String] = [] var loadedSkillIds: [String] = [] for lsCall in loadSkillCalls { - let skillName = (lsCall.arguments["skill"] as? String) + let requestedSkillName = (lsCall.arguments["skill"] as? String) ?? (lsCall.arguments["name"] as? String) ?? "" - log("[Agent] load_skill: \(skillName)") + let skillName = skillLoader.canonicalSkillId(for: requestedSkillName) + log("[Agent] load_skill: \(requestedSkillName)") let displayName = findDisplayName(for: skillName) loadedDisplayNames.append(displayName) @@ -1327,7 +1738,7 @@ class AgentEngine { return } - let singleToolExtraction = await extractSingleToolCallForLoadedSkill( + let singleToolExtraction = await extractToolCallForLoadedSkills( originalPrompt: prompt, userQuestion: userQuestion, skillInstructions: allInstructions, @@ -1472,6 +1883,12 @@ class AgentEngine { messages.append(ChatMessage(role: .skillResult, content: toolResultSummary, skillName: call.name)) log("[Agent] Tool \(call.name) round \(round) done") + if shouldSkipToolFollowUpModel(for: call.name) { + messages.append(ChatMessage(role: .assistant, content: toolResultSummary)) + isProcessing = false + return + } + let followUpPrompt = PromptBuilder.buildToolAnswerPrompt( originalPrompt: prompt, toolName: call.name, diff --git a/LLM/MLXLocalLLMService.swift b/LLM/MLXLocalLLMService.swift index a64f282..55cf5e7 100644 --- a/LLM/MLXLocalLLMService.swift +++ b/LLM/MLXLocalLLMService.swift @@ -14,6 +14,17 @@ public struct BundledModelOption: Identifiable, Hashable, Sendable { public let id: String public let directoryName: String public let displayName: String + public let repositoryID: String + public let requiredFiles: [String] +} + +public enum ModelInstallState: Equatable, Sendable { + case notInstalled + case checkingSource + case downloading(completedFiles: Int, totalFiles: Int, currentFile: String) + case downloaded + case bundled + case failed(String) } /// MLX GPU inference service for Gemma 4. @@ -24,12 +35,34 @@ public class MLXLocalLLMService: LLMEngine { .init( id: "gemma-4-e2b-it-4bit", directoryName: "gemma-4-e2b-it-4bit", - displayName: "Gemma 4 E2B" + displayName: "Gemma 4 E2B", + repositoryID: "mlx-community/gemma-4-e2b-it-4bit", + requiredFiles: [ + "config.json", + "generation_config.json", + "model.safetensors", + "model.safetensors.index.json", + "processor_config.json", + "tokenizer.json", + "tokenizer_config.json", + "chat_template.jinja" + ] ), .init( id: "gemma-4-e4b-it-4bit", directoryName: "gemma-4-e4b-it-4bit", - displayName: "Gemma 4 E4B" + displayName: "Gemma 4 E4B", + repositoryID: "mlx-community/gemma-4-e4b-it-4bit", + requiredFiles: [ + "config.json", + "generation_config.json", + "model.safetensors", + "model.safetensors.index.json", + "processor_config.json", + "tokenizer.json", + "tokenizer_config.json", + "chat_template.jinja" + ] ) ] static let defaultModel = availableModels[0] @@ -47,6 +80,7 @@ public class MLXLocalLLMService: LLMEngine { public var modelDisplayName: String { loadedModel?.displayName ?? selectedModel.displayName } public var selectedModelID: String { selectedModel.id } public var loadedModelID: String? { loadedModel?.id } + public private(set) var modelInstallStates: [String: ModelInstallState] = [:] // MARK: - Compatibility Settings @@ -60,6 +94,10 @@ public class MLXLocalLLMService: LLMEngine { private var cancelled = false private var currentLoadTask: Task? private var currentGenerationTask: Task? + private var currentDownloadTasks: [String: Task] = [:] + private let foregroundStateLock = NSLock() + private var foregroundGPUAllowed = true + private var lifecycleObserverTokens: [NSObjectProtocol] = [] /// Local path to the model directory private var modelPath: URL { @@ -74,6 +112,15 @@ public class MLXLocalLLMService: LLMEngine { self.selectedModel = option } self.stats.backend = "mlx-gpu" + configureLifecycleObservers() + cleanupStalePartialDirectories() + refreshModelInstallStates() + } + + deinit { + for token in lifecycleObserverTokens { + NotificationCenter.default.removeObserver(token) + } } /// Convenience init with default model location @@ -95,28 +142,195 @@ public class MLXLocalLLMService: LLMEngine { } private static func resolveModelPath(for model: BundledModelOption) -> URL { - if let resourceURL = Bundle.main.resourceURL { - let directBundleDir = resourceURL.appendingPathComponent( - model.directoryName, - isDirectory: true - ) - if FileManager.default.fileExists(atPath: directBundleDir.path) { - return directBundleDir - } - - let nestedBundleDir = resourceURL - .appendingPathComponent("Models", isDirectory: true) - .appendingPathComponent(model.directoryName, isDirectory: true) - if FileManager.default.fileExists(atPath: nestedBundleDir.path) { - return nestedBundleDir - } + if let bundledPath = bundledModelPath(for: model) { + return bundledPath } + return downloadedModelPath(for: model) + } + + private static func documentsModelsRoot() -> URL { let documentsPath = FileManager.default.urls( for: .documentDirectory, in: .userDomainMask ).first! - return documentsPath.appendingPathComponent("models/\(model.directoryName)") + return documentsPath.appendingPathComponent("models", isDirectory: true) + } + + private static func downloadedModelPath(for model: BundledModelOption) -> URL { + documentsModelsRoot().appendingPathComponent(model.directoryName, isDirectory: true) + } + + private static func partialModelPath(for model: BundledModelOption) -> URL { + documentsModelsRoot().appendingPathComponent("\(model.directoryName).partial", isDirectory: true) + } + + private static func bundledModelPath(for model: BundledModelOption) -> URL? { + guard let resourceURL = Bundle.main.resourceURL else { return nil } + + let directBundleDir = resourceURL.appendingPathComponent( + model.directoryName, + isDirectory: true + ) + if hasRequiredFiles(for: model, at: directBundleDir) { + return directBundleDir + } + + let nestedBundleDir = resourceURL + .appendingPathComponent("Models", isDirectory: true) + .appendingPathComponent(model.directoryName, isDirectory: true) + if hasRequiredFiles(for: model, at: nestedBundleDir) { + return nestedBundleDir + } + + return nil + } + + private static func hasRequiredFiles(for model: BundledModelOption, at directory: URL) -> Bool { + let fm = FileManager.default + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: directory.path, isDirectory: &isDirectory), isDirectory.boolValue else { + return false + } + return model.requiredFiles.allSatisfy { file in + fm.fileExists(atPath: directory.appendingPathComponent(file).path) + } + } + + public func isModelAvailable(_ model: BundledModelOption) -> Bool { + Self.bundledModelPath(for: model) != nil + || Self.hasRequiredFiles(for: model, at: Self.downloadedModelPath(for: model)) + } + + public func installState(for model: BundledModelOption) -> ModelInstallState { + if Self.bundledModelPath(for: model) != nil { + return .bundled + } + if Self.hasRequiredFiles(for: model, at: Self.downloadedModelPath(for: model)) { + return .downloaded + } + return modelInstallStates[model.id] ?? .notInstalled + } + + public func refreshModelInstallStates() { + cleanupStalePartialDirectories() + for model in Self.availableModels { + if Self.bundledModelPath(for: model) != nil { + modelInstallStates[model.id] = .bundled + } else if Self.hasRequiredFiles(for: model, at: Self.downloadedModelPath(for: model)) { + modelInstallStates[model.id] = .downloaded + } else if case .checkingSource = modelInstallStates[model.id] { + continue + } else if case .downloading = modelInstallStates[model.id] { + continue + } else { + modelInstallStates[model.id] = .notInstalled + } + } + } + + private func cleanupStalePartialDirectories() { + let fm = FileManager.default + for model in Self.availableModels { + let partialDirectory = Self.partialModelPath(for: model) + if fm.fileExists(atPath: partialDirectory.path) { + try? fm.removeItem(at: partialDirectory) + } + } + } + + private func huggingFaceURL(for model: BundledModelOption, file: String) -> URL? { + let rawPath = "\(model.repositoryID)/resolve/main/\(file)" + return URL(string: "https://huggingface.co/" + rawPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!.replacingOccurrences(of: "%2F", with: "/")) + } + + public func downloadModel(id: String) async { + guard let model = Self.availableModels.first(where: { $0.id == id }) else { return } + if isModelAvailable(model) { + refreshModelInstallStates() + return + } + if currentDownloadTasks[id] != nil { + return + } + + let task = Task { [weak self] in + guard let self else { return } + let fm = FileManager.default + let modelsRoot = Self.documentsModelsRoot() + let finalDirectory = Self.downloadedModelPath(for: model) + let partialDirectory = Self.partialModelPath(for: model) + + await MainActor.run { + self.modelInstallStates[id] = .checkingSource + } + + do { + if !fm.fileExists(atPath: modelsRoot.path) { + try fm.createDirectory(at: modelsRoot, withIntermediateDirectories: true) + } + if fm.fileExists(atPath: partialDirectory.path) { + try fm.removeItem(at: partialDirectory) + } + try fm.createDirectory(at: partialDirectory, withIntermediateDirectories: true) + + let totalFiles = model.requiredFiles.count + for (index, file) in model.requiredFiles.enumerated() { + guard let url = huggingFaceURL(for: model, file: file) else { + throw DownloadError.invalidURL(file) + } + + await MainActor.run { + self.modelInstallStates[id] = .downloading( + completedFiles: index, + totalFiles: totalFiles, + currentFile: file + ) + } + + let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 1800) + let (temporaryURL, response) = try await URLSession.shared.download(for: request) + guard let http = response as? HTTPURLResponse else { + throw DownloadError.invalidResponse + } + guard (200...299).contains(http.statusCode) else { + throw DownloadError.httpStatus(http.statusCode) + } + + let destinationURL = partialDirectory.appendingPathComponent(file) + let parentDirectory = destinationURL.deletingLastPathComponent() + if !fm.fileExists(atPath: parentDirectory.path) { + try fm.createDirectory(at: parentDirectory, withIntermediateDirectories: true) + } + if fm.fileExists(atPath: destinationURL.path) { + try fm.removeItem(at: destinationURL) + } + try fm.moveItem(at: temporaryURL, to: destinationURL) + } + + if fm.fileExists(atPath: finalDirectory.path) { + try fm.removeItem(at: finalDirectory) + } + try fm.moveItem(at: partialDirectory, to: finalDirectory) + + await MainActor.run { + self.modelInstallStates[id] = .downloaded + self.refreshModelInstallStates() + } + } catch { + try? fm.removeItem(at: partialDirectory) + await MainActor.run { + self.modelInstallStates[id] = .failed(error.localizedDescription) + } + } + + await MainActor.run { + self.currentDownloadTasks[id] = nil + } + } + + currentDownloadTasks[id] = task + await task.value } func loadModel() { @@ -137,7 +351,15 @@ public class MLXLocalLLMService: LLMEngine { } } } catch { - statusMessage = "❌ \(error.localizedDescription)" + if let mlxError = error as? MLXError, + case .modelDirectoryMissing = mlxError { + statusMessage = "请在配置中下载 \(self.selectedModel.displayName) 模型" + } else { + statusMessage = "❌ \(error.localizedDescription)" + } + self.isLoaded = false + self.loadedModel = nil + self.refreshModelInstallStates() print("[MLX] Load failed: \(error)") } } @@ -213,7 +435,7 @@ public class MLXLocalLLMService: LLMEngine { statusMessage = "正在初始化模型..." await Gemma4Registration.register() - guard FileManager.default.fileExists(atPath: path.path) else { + guard Self.hasRequiredFiles(for: model, at: path) else { throw MLXError.modelDirectoryMissing(path.path) } @@ -328,12 +550,77 @@ public class MLXLocalLLMService: LLMEngine { let isActive = await MainActor.run { UIApplication.shared.applicationState == .active } + setForegroundGPUAllowed(isActive) guard isActive else { throw MLXError.gpuExecutionRequiresForeground } #endif } + private func configureLifecycleObservers() { + #if canImport(UIKit) + let center = NotificationCenter.default + lifecycleObserverTokens = [ + center.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.handleApplicationLeavingForeground() + }, + center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.handleApplicationLeavingForeground() + }, + center.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.setForegroundGPUAllowed(true) + }, + center.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.setForegroundGPUAllowed(true) + } + ] + + Task { [weak self] in + guard let self else { return } + let isActive = await MainActor.run { + UIApplication.shared.applicationState == .active + } + self.setForegroundGPUAllowed(isActive) + } + #endif + } + + private func handleApplicationLeavingForeground() { + setForegroundGPUAllowed(false) + cancelled = true + currentGenerationTask?.cancel() + currentLoadTask?.cancel() + } + + private func setForegroundGPUAllowed(_ allowed: Bool) { + foregroundStateLock.lock() + foregroundGPUAllowed = allowed + foregroundStateLock.unlock() + } + + private func isForegroundGPUAllowed() -> Bool { + foregroundStateLock.lock() + let allowed = foregroundGPUAllowed + foregroundStateLock.unlock() + return allowed + } + private func generateStream( input: UserInput, isMultimodal: Bool @@ -390,7 +677,7 @@ public class MLXLocalLLMService: LLMEngine { ), context: context ) { tokens in - if self.cancelled { + if self.cancelled || !self.isForegroundGPUAllowed() { return .stop } @@ -501,3 +788,29 @@ enum MLXError: LocalizedError { } } } + +enum DownloadError: LocalizedError { + case invalidURL(String) + case invalidResponse + case httpStatus(Int) + + var errorDescription: String? { + switch self { + case .invalidURL(let file): + return "无法构造下载链接:\(file)" + case .invalidResponse: + return "下载源响应无效" + case .httpStatus(let statusCode): + switch statusCode { + case 401, 403: + return "下载源拒绝访问(\(statusCode))" + case 404: + return "模型文件不存在(404)" + case 429: + return "下载过于频繁,请稍后重试(429)" + default: + return "下载失败,HTTP \(statusCode)" + } + } + } +} diff --git a/LLM/PromptBuilder.swift b/LLM/PromptBuilder.swift index 42432f6..eac753c 100644 --- a/LLM/PromptBuilder.swift +++ b/LLM/PromptBuilder.swift @@ -234,4 +234,52 @@ struct PromptBuilder { """ } + + /// 单 Skill + 多工具时,让模型只在允许的工具集合中选择一个工具并抽取 arguments。 + static func buildSkillToolSelectionPrompt( + originalPrompt: String, + userQuestion: String, + skillInstructions: String, + allowedToolsSummary: String, + currentImageCount: Int = 0 + ) -> String { + let systemBlock = extractSystemBlock(from: originalPrompt) + let systemInstructions = injectIntoSystemBlock( + systemBlock, + extraInstructions: """ + 对于当前这一个用户问题,你已经加载了所需的 Skill 指令。 + 不要再次调用 `load_skill`。 + + 已加载的 Skill 指令: + \(skillInstructions) + """ + ) + + return systemInstructions + """ + <|turn>user + 用户问题: + \(userQuestion)\(imagePromptSuffix(count: currentImageCount)) + + 你现在只负责两件事: + 1. 在下面允许的工具里选择最合适的一个 + 2. 为该工具提取 arguments + + 允许的工具: + \(allowedToolsSummary) + + 严格遵守以下要求: + 1. 不要调用工具,不要输出 ``。 + 2. 只输出一个 JSON object,格式必须是: + {"name":"工具名","arguments":{"参数名":"参数值"}} + 3. `name` 必须是上面允许的工具之一。 + 4. `arguments` 里只保留当前工具需要的参数;没有的可选参数直接省略。 + 5. 不要输出 Markdown、代码块、解释、草稿或多余文字。 + 6. 时间字段必须转换成 ISO 8601,例如 `2026-04-07T20:00:00`。 + 7. 如果缺少执行所需的关键信息,输出: + {"_needs_clarification":"请补充缺少的信息"} + + <|turn>model + + """ + } } diff --git a/Skills/SkillLoader.swift b/Skills/SkillLoader.swift index 2397003..97df08f 100644 --- a/Skills/SkillLoader.swift +++ b/Skills/SkillLoader.swift @@ -53,6 +53,10 @@ struct SkillDefinition: Identifiable { // MARK: - Skill Loader class SkillLoader { + private static let skillAliases: [String: String] = [ + "contacts_delete": "contacts", + "contacts-delete": "contacts" + ] let skillsDirectory: URL private var cache: [String: SkillDefinition] = [:] @@ -84,6 +88,9 @@ class SkillLoader { guard FileManager.default.fileExists(atPath: skillFile.path) else { continue } let skillId = item.lastPathComponent + if Self.skillAliases[skillId] != nil { + continue + } if let def = loadDefinition(skillId: skillId, file: skillFile) { cache[skillId] = def results.append(def) @@ -94,16 +101,17 @@ class SkillLoader { /// 完整加载 Skill(包括 body)— load_skill 时调用 func loadBody(skillId: String) -> String? { - if let cached = cache[skillId], cached.body != nil { + let resolvedSkillId = canonicalSkillId(for: skillId) + if let cached = cache[resolvedSkillId], cached.body != nil { return cached.body } let skillFile = skillsDirectory - .appendingPathComponent(skillId, isDirectory: true) + .appendingPathComponent(resolvedSkillId, isDirectory: true) .appendingPathComponent("SKILL.md") guard let content = try? String(contentsOf: skillFile, encoding: .utf8) else { return nil } let body = parseBody(content) - cache[skillId]?.body = body + cache[resolvedSkillId]?.body = body return body } @@ -134,12 +142,16 @@ class SkillLoader { /// 获取缓存的 SkillDefinition func getDefinition(_ skillId: String) -> SkillDefinition? { - cache[skillId] + cache[canonicalSkillId(for: skillId)] } /// 更新启用状态 func setEnabled(_ skillId: String, enabled: Bool) { - cache[skillId]?.isEnabled = enabled + cache[canonicalSkillId(for: skillId)]?.isEnabled = enabled + } + + func canonicalSkillId(for skillId: String) -> String { + Self.skillAliases[skillId] ?? skillId } // MARK: - 解析 @@ -502,50 +514,83 @@ class SkillLoader { --- name: Contacts name-zh: 通讯录 - description: '创建或更新联系人。当用户要存号码、保存联系方式或补充联系人信息时使用。' - version: "1.0.0" + description: '查询、创建、更新或删除联系人。当用户要查电话、看联系方式、存号码、补充联系人信息或删除联系人时使用。' + version: "1.1.0" icon: person.crop.circle disabled: false triggers: - 联系人 - 通讯录 + - 查电话 + - 联系电话 - 存号码 - 联系方式 + - 删除联系人 allowed-tools: + - contacts-search - contacts-upsert + - contacts-delete examples: - - query: "帮我存一下王总的电话 13812345678" + - query: "把王总电话 13812345678 添加到联系人" scenario: "新建或更新联系人" + - query: "检查下联系人张晓霞的电话多少" + scenario: "查询联系人电话" + - query: "把王总从联系人中删除" + scenario: "删除联系人" --- - # 联系人创建与更新 + # 联系人查询与维护 - 你负责帮助用户创建或更新通讯录联系人。 + 你负责帮助用户查询、创建、更新或删除通讯录联系人。 ## 可用工具 + - **contacts-search**: 查询联系人 + - `query`: 关键词,可用于模糊搜索 + - `name`: 联系人姓名 + - `phone`: 手机号 + - `email`: 邮箱 + - `identifier`: 联系人标识 - **contacts-upsert**: 创建或更新联系人 - `name`: 必填,联系人姓名 - `phone`: 可选,手机号;如果提供,会优先按手机号查重 - `company`: 可选,公司 - `email`: 可选,邮箱 - `notes`: 可选,备注 + - **contacts-delete**: 删除联系人 + - `query`: 关键词,可用于模糊搜索 + - `name`: 联系人姓名 + - `phone`: 手机号 + - `email`: 邮箱 + - `identifier`: 联系人标识 ## 执行流程 - 1. 只有当用户明确要保存、更新联系人信息时才调用工具 - 2. 由你提取姓名、手机号、公司、邮箱、备注 - 3. 如果缺少 `name`,先简短追问 - 4. 工具成功后,直接告诉用户联系人已创建或已更新 + 1. 如果用户在查询电话、邮箱、联系方式,优先调用 `contacts-search` + 2. 如果用户在删除、移除、删掉联系人,调用 `contacts-delete` + 3. 如果用户在保存、添加或更新联系人,调用 `contacts-upsert` + 4. 查询或删除时优先提取 `name`,提取不到再用 `query` + 5. 保存或更新时提取姓名、手机号、公司、邮箱、备注 + 6. 如果缺少保存联系人所需的 `name`,先简短追问 + 7. 删除时如果匹配到多个联系人,不要猜测,应提示用户说得更具体 + 8. 工具成功后,直接用中文给出简洁结果 ## 调用格式 + + {"name": "contacts-search", "arguments": {"name": "张晓霞"}} + + {"name": "contacts-upsert", "arguments": {"name": "王总", "phone": "13812345678", "company": "字节"}} + + + {"name": "contacts-delete", "arguments": {"name": "王总"}} + """), ] } diff --git a/Skills/ToolRegistry.swift b/Skills/ToolRegistry.swift index 59dad5a..bdb082d 100644 --- a/Skills/ToolRegistry.swift +++ b/Skills/ToolRegistry.swift @@ -27,7 +27,7 @@ enum AppPermissionKind: String, CaseIterable, Identifiable { switch self { case .calendar: return "允许创建和写入日历事项" case .reminders: return "允许创建提醒和待办" - case .contacts: return "允许保存和更新联系人" + case .contacts: return "允许查询、保存和删除联系人" } } @@ -383,10 +383,12 @@ class ToolRegistry { CNContactIdentifierKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, + CNContactMiddleNameKey as CNKeyDescriptor, + CNContactNicknameKey as CNKeyDescriptor, + CNContactJobTitleKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactOrganizationNameKey as CNKeyDescriptor, - CNContactEmailAddressesKey as CNKeyDescriptor, - CNContactNoteKey as CNKeyDescriptor + CNContactEmailAddressesKey as CNKeyDescriptor ] } @@ -403,6 +405,213 @@ class ToolRegistry { ).first } + private func allContacts() throws -> [CNContact] { + var contacts: [CNContact] = [] + let request = CNContactFetchRequest(keysToFetch: contactKeysToFetch()) + request.sortOrder = .userDefault + try contactStore.enumerateContacts(with: request) { contact, _ in + contacts.append(contact) + } + return contacts + } + + private func formattedContactName(_ contact: CNContact) -> String { + let manual = [contact.familyName, contact.middleName, contact.givenName] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined() + if !manual.isEmpty { + return manual + } + + let nickname = contact.nickname.trimmingCharacters(in: .whitespacesAndNewlines) + if !nickname.isEmpty { + return nickname + } + + let organization = contact.organizationName.trimmingCharacters(in: .whitespacesAndNewlines) + if !organization.isEmpty { + return organization + } + + return "未命名联系人" + } + + private func clipboardTextPreview( + from text: String, + maxCharacters: Int = 500 + ) -> (preview: String, truncated: Bool)? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let endIndex = trimmed.index( + trimmed.startIndex, + offsetBy: maxCharacters, + limitedBy: trimmed.endIndex + ) ?? trimmed.endIndex + + return ( + preview: String(trimmed[.. [String] { + [ + formattedContactName(contact), + contact.familyName, + contact.middleName, + contact.givenName, + contact.nickname, + contact.organizationName, + contact.jobTitle + ] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + private func relaxedSearchAliases(for raw: String) -> [String] { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + var aliases = [trimmed] + let suffixes = ["总经理", "经理", "总监", "老板", "老师", "医生", "主任", "总", "哥", "姐"] + for suffix in suffixes where trimmed.hasSuffix(suffix) { + let candidate = String(trimmed.dropLast(suffix.count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if candidate.count >= 2 { + aliases.append(candidate) + } + } + + let prefixes = ["老", "小", "阿"] + for prefix in prefixes where trimmed.hasPrefix(prefix) { + let candidate = String(trimmed.dropFirst(prefix.count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if candidate.count >= 2 { + aliases.append(candidate) + } + } + + return Array(NSOrderedSet(array: aliases)) as? [String] ?? aliases + } + + private func primaryPhone(_ contact: CNContact) -> String? { + contact.phoneNumbers + .map { $0.value.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { !$0.isEmpty }) + } + + private func primaryEmail(_ contact: CNContact) -> String? { + contact.emailAddresses + .map { String($0.value).trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { !$0.isEmpty }) + } + + private func contactSummaryDictionary(_ contact: CNContact) -> [String: Any] { + [ + "identifier": contact.identifier, + "name": formattedContactName(contact), + "phone": primaryPhone(contact) ?? "", + "company": contact.organizationName, + "email": primaryEmail(contact) ?? "" + ] + } + + private func contactSummaryText(_ contact: CNContact) -> String { + var parts = [formattedContactName(contact)] + if let phone = primaryPhone(contact) { + parts.append("电话 \(phone)") + } + if let email = primaryEmail(contact) { + parts.append("邮箱 \(email)") + } + let company = contact.organizationName.trimmingCharacters(in: .whitespacesAndNewlines) + if !company.isEmpty { + parts.append("公司 \(company)") + } + return parts.joined(separator: ",") + } + + private func searchContacts( + identifier: String? = nil, + name: String? = nil, + phone: String? = nil, + email: String? = nil, + query: String? = nil + ) throws -> [CNContact] { + let identifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let name = name?.trimmingCharacters(in: .whitespacesAndNewlines) + let phone = phone?.trimmingCharacters(in: .whitespacesAndNewlines) + let email = email?.trimmingCharacters(in: .whitespacesAndNewlines) + let query = query?.trimmingCharacters(in: .whitespacesAndNewlines) + + let candidates: [CNContact] + if let identifier, !identifier.isEmpty { + candidates = try contactStore.unifiedContacts( + matching: CNContact.predicateForContacts(withIdentifiers: [identifier]), + keysToFetch: contactKeysToFetch() + ) + } else { + candidates = try allContacts() + } + + let matches = candidates.filter { contact in + if let identifier, !identifier.isEmpty, contact.identifier != identifier { + return false + } + + if let name, !name.isEmpty { + let aliases = relaxedSearchAliases(for: name) + let searchTexts = contactSearchTexts(contact) + let matched = aliases.contains { alias in + searchTexts.contains { $0.localizedCaseInsensitiveContains(alias) } + } + if !matched { + return false + } + } + + if let phone, !phone.isEmpty, + !contact.phoneNumbers.contains(where: { + $0.value.stringValue.localizedCaseInsensitiveContains(phone) + }) { + return false + } + + if let email, !email.isEmpty, + !contact.emailAddresses.contains(where: { + String($0.value).localizedCaseInsensitiveContains(email) + }) { + return false + } + + if let query, !query.isEmpty { + let aliases = relaxedSearchAliases(for: query) + let textMatch = aliases.contains { alias in + contactSearchTexts(contact).contains { + $0.localizedCaseInsensitiveContains(alias) + } + } + let phoneMatch = contact.phoneNumbers.contains { + $0.value.stringValue.localizedCaseInsensitiveContains(query) + } + let emailMatch = contact.emailAddresses.contains { + String($0.value).localizedCaseInsensitiveContains(query) + } + if !(textMatch || phoneMatch || emailMatch) { + return false + } + } + + return true + } + + return matches.sorted { + formattedContactName($0).localizedCaseInsensitiveCompare(formattedContactName($1)) == .orderedAscending + } + } + // MARK: - 内置工具注册 private func registerBuiltInTools() { @@ -463,21 +672,96 @@ class ToolRegistry { description: "读取剪贴板当前内容", parameters: "无" ) { _ in - let content = await MainActor.run { UIPasteboard.general.string } - if let raw = content, !raw.isEmpty { - let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return failurePayload(error: "剪贴板为空") } - let preview = String(text.prefix(500)) + let snapshot = await MainActor.run { () -> [String: Any] in + let pasteboard = UIPasteboard.general + + if pasteboard.numberOfItems == 0 { + return ["kind": "empty"] + } + + if pasteboard.hasImages { + return [ + "kind": "image", + "item_count": pasteboard.numberOfItems + ] + } + + if pasteboard.hasURLs, + let urlText = pasteboard.url?.absoluteString, + let preview = self.clipboardTextPreview(from: urlText, maxCharacters: 500) { + return [ + "kind": "url", + "content": preview.preview, + "truncated": preview.truncated + ] + } + + if pasteboard.hasStrings, + let raw = pasteboard.string, + let preview = self.clipboardTextPreview(from: raw, maxCharacters: 500) { + return [ + "kind": "text", + "content": preview.preview, + "truncated": preview.truncated + ] + } + + return [ + "kind": "unsupported", + "item_count": pasteboard.numberOfItems + ] + } + + switch snapshot["kind"] as? String { + case "text": + let preview = snapshot["content"] as? String ?? "" + let truncated = snapshot["truncated"] as? Bool ?? false + let suffix = truncated ? "(内容较长,已截断显示)" : "" return successPayload( - result: "剪贴板当前内容是:\(preview)", + result: "剪贴板当前文本内容是:\(preview)\(suffix)", extras: [ "type": "text", "content": preview, - "length": text.count + "truncated": truncated ] ) + + case "url": + let preview = snapshot["content"] as? String ?? "" + let truncated = snapshot["truncated"] as? Bool ?? false + let suffix = truncated ? "(内容较长,已截断显示)" : "" + return successPayload( + result: "剪贴板当前是链接:\(preview)\(suffix)", + extras: [ + "type": "url", + "content": preview, + "truncated": truncated + ] + ) + + case "image": + let itemCount = snapshot["item_count"] as? Int ?? 1 + return successPayload( + result: "剪贴板当前是图片内容。为避免额外内存占用,暂不直接解码图片。", + extras: [ + "type": "image", + "item_count": itemCount + ] + ) + + case "unsupported": + let itemCount = snapshot["item_count"] as? Int ?? 1 + return successPayload( + result: "剪贴板当前包含 \(itemCount) 项非文本内容,暂不直接读取。", + extras: [ + "type": "unsupported", + "item_count": itemCount + ] + ) + + default: + return failurePayload(error: "剪贴板为空") } - return failurePayload(error: "剪贴板为空") }) register(RegisteredTool( @@ -863,6 +1147,130 @@ class ToolRegistry { return failurePayload(error: "保存联系人失败:\(error.localizedDescription)") } }) + + register(RegisteredTool( + name: "contacts-search", + description: "搜索联系人,可按姓名、手机号、邮箱、identifier 或关键词查询联系方式", + parameters: "query: 搜索关键词(可选), identifier: 联系人标识(可选), name: 姓名(可选), phone: 手机号(可选), email: 邮箱(可选)" + ) { args in + let identifier = (args["identifier"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let name = (args["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let phone = (args["phone"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let email = (args["email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let query = (args["query"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + + guard identifier?.isEmpty == false + || name?.isEmpty == false + || phone?.isEmpty == false + || email?.isEmpty == false + || query?.isEmpty == false else { + return failurePayload(error: "请至少提供 query、name、phone、email 或 identifier 其中一个参数") + } + + do { + guard try await self.requestContactsAccess() else { + return failurePayload(error: "未获得通讯录权限") + } + + let matches = Array(try self.searchContacts( + identifier: identifier, + name: name, + phone: phone, + email: email, + query: query + ).prefix(5)) + + let items = matches.map(self.contactSummaryDictionary) + if matches.isEmpty { + return successPayload( + result: "未找到匹配的联系人。", + extras: [ + "count": 0, + "items": items + ] + ) + } + + let lines = matches.map(self.contactSummaryText) + return successPayload( + result: "找到 \(matches.count) 个联系人:\(lines.joined(separator: ";"))。", + extras: [ + "count": matches.count, + "items": items + ] + ) + } catch { + return failurePayload(error: "搜索联系人失败:\(error.localizedDescription)") + } + }) + + register(RegisteredTool( + name: "contacts-delete", + description: "删除联系人,可按姓名、手机号、邮箱、identifier 或关键词匹配后删除", + parameters: "query: 搜索关键词(可选), identifier: 联系人标识(可选), name: 姓名(可选), phone: 手机号(可选), email: 邮箱(可选)" + ) { args in + let identifier = (args["identifier"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let rawName = (args["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let phone = (args["phone"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let email = (args["email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let query = (args["query"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let name = rawName? + .replacingOccurrences(of: "的电话", with: "") + .replacingOccurrences(of: "电话", with: "") + .replacingOccurrences(of: "手机号", with: "") + .replacingOccurrences(of: "号码", with: "") + .replacingOccurrences(of: "联系方式", with: "") + .trimmingCharacters(in: CharacterSet(charactersIn: ",。,?!!? ")) + + guard identifier?.isEmpty == false + || name?.isEmpty == false + || phone?.isEmpty == false + || email?.isEmpty == false + || query?.isEmpty == false else { + return failurePayload(error: "请至少提供 query、name、phone、email 或 identifier 其中一个参数") + } + + do { + guard try await self.requestContactsAccess() else { + return failurePayload(error: "未获得通讯录权限") + } + + let matches = try self.searchContacts( + identifier: identifier, + name: name, + phone: phone, + email: email, + query: query + ) + + if matches.isEmpty { + return failurePayload(error: "未找到匹配的联系人") + } + + if matches.count > 1 { + let previews = matches.prefix(5).map(self.contactSummaryText).joined(separator: ";") + return failurePayload(error: "匹配到多个联系人,请提供更具体的信息:\(previews)") + } + + let contact = matches[0] + let mutableContact = contact.mutableCopy() as! CNMutableContact + let saveRequest = CNSaveRequest() + saveRequest.delete(mutableContact) + try self.contactStore.execute(saveRequest) + + return successPayload( + result: "已删除联系人“\(self.formattedContactName(contact))”。", + extras: [ + "identifier": contact.identifier, + "name": self.formattedContactName(contact), + "phone": self.primaryPhone(contact) ?? "", + "email": self.primaryEmail(contact) ?? "" + ] + ) + } catch { + return failurePayload(error: "删除联系人失败:\(error.localizedDescription)") + } + }) } } diff --git a/UI/ConfigurationsView.swift b/UI/ConfigurationsView.swift index c9b2b56..e300b48 100644 --- a/UI/ConfigurationsView.swift +++ b/UI/ConfigurationsView.swift @@ -56,8 +56,9 @@ struct ConfigurationsView: View { Button(localized("取消", "Cancel")) { dismiss() } .foregroundStyle(Theme.textSecondary) Button(localized("确定", "OK")) { - applySettings() - dismiss() + if applySettings() { + dismiss() + } } .font(.body.weight(.semibold)) .foregroundStyle(Theme.accent) @@ -76,6 +77,7 @@ struct ConfigurationsView: View { .onAppear { loadCurrentSettings() } #if canImport(UIKit) .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + engine.llm.refreshModelInstallStates() refreshPermissionStatuses() } #endif @@ -184,9 +186,8 @@ struct ConfigurationsView: View { VStack(spacing: 10) { ForEach(engine.availableModels) { model in - Button { - selectedModelID = model.id - } label: { + let state = engine.llm.installState(for: model) + VStack(alignment: .leading, spacing: 10) { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(model.displayName) @@ -199,34 +200,45 @@ struct ConfigurationsView: View { Spacer() - if selectedModelID == model.id { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Theme.accent) - } else { - Image(systemName: "circle") - .foregroundStyle(Theme.textTertiary) + VStack(alignment: .trailing, spacing: 8) { + modelStateControl(for: model, state: state) + + if selectedModelID == model.id { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Theme.accent) + } else { + Image(systemName: "circle") + .foregroundStyle(Theme.textTertiary) + } } } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(selectedModelID == model.id ? Theme.accentSubtle : Theme.bg) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder( - selectedModelID == model.id ? Theme.accent : Theme.border, - lineWidth: 1 - ) - ) + + if let detail = modelStateDetail(state) { + Text(detail) + .font(.caption) + .foregroundStyle(state.isFailure ? Theme.accent : Theme.textTertiary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(selectedModelID == model.id ? Theme.accentSubtle : Theme.bg) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + selectedModelID == model.id ? Theme.accent : Theme.border, + lineWidth: 1 + ) + ) + .contentShape(RoundedRectangle(cornerRadius: 12)) + .onTapGesture { + selectedModelID = model.id } - .buttonStyle(.plain) } } - Text(selectedModelID == engine.llm.selectedModelID - ? localized("点击确定会保留当前模型。", "Tap OK to keep the current model.") - : localized("点击确定后会卸载当前模型并重新加载新模型。", "Tap OK to unload the current model and reload the new one.")) + Text(modelFooterText) .font(.caption) .foregroundStyle(Theme.textTertiary) } @@ -349,6 +361,94 @@ struct ConfigurationsView: View { } } + @ViewBuilder + private func modelStateControl(for model: BundledModelOption, state: ModelInstallState) -> some View { + switch state { + case .notInstalled: + Button(localized("下载", "Download")) { + selectedModelID = model.id + Task { + await engine.llm.downloadModel(id: model.id) + if engine.llm.isModelAvailable(model), + selectedModelID == model.id, + (!engine.llm.isLoaded || engine.llm.loadedModelID != model.id) { + engine.config.selectedModelID = model.id + engine.reloadModel() + } + } + } + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.accent) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Theme.accent.opacity(0.15), in: Capsule()) + case .checkingSource: + modelBadge(localized("检查中", "Checking")) + case .downloading(let completedFiles, let totalFiles, _): + modelBadge(localized("下载中 \(completedFiles)/\(totalFiles)", "Downloading \(completedFiles)/\(totalFiles)")) + case .downloaded: + modelBadge(localized("已下载", "Downloaded"), color: Theme.accentGreen) + case .bundled: + modelBadge(localized("内置", "Bundled"), color: Theme.accentGreen) + case .failed: + Button(localized("重试", "Retry")) { + selectedModelID = model.id + Task { + await engine.llm.downloadModel(id: model.id) + } + } + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.accent) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Theme.accent.opacity(0.15), in: Capsule()) + } + } + + private func modelBadge(_ text: String, color: Color = Theme.textTertiary) -> some View { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.14), in: Capsule()) + } + + private func modelStateDetail(_ state: ModelInstallState) -> String? { + switch state { + case .notInstalled: + return localized("未安装", "Not Installed") + case .checkingSource: + return localized("正在检查模型下载源。", "Checking the model download source.") + case .downloading(_, _, let currentFile): + return localized("正在下载:", "Downloading: ") + currentFile + case .downloaded: + return localized("已下载到手机本地,可直接加载。", "Downloaded on device and ready to load.") + case .bundled: + return localized("模型已随 App 内置。", "This model is bundled inside the app.") + case .failed(let message): + return message + } + } + + private var modelFooterText: String { + guard let selectedModel = engine.availableModels.first(where: { $0.id == selectedModelID }) else { + return localized("点右侧按钮下载模型后再点击确定。", "Download a model first, then tap OK.") + } + + if !engine.llm.isModelAvailable(selectedModel) { + return localized("先下载选中的模型,再点击确定加载。", "Download the selected model first, then tap OK to load it.") + } + + if selectedModelID == engine.llm.selectedModelID, + engine.llm.loadedModelID == selectedModelID, + engine.llm.isLoaded { + return localized("点击确定会保留当前模型。", "Tap OK to keep the current model.") + } + + return localized("点击确定后会卸载当前模型并重新加载新模型。", "Tap OK to unload the current model and reload the new one.") + } + // MARK: - 加载 / 应用 private func localized(_ zh: String, _ en: String) -> String { @@ -404,7 +504,8 @@ struct ConfigurationsView: View { } private func loadCurrentSettings() { - selectedModelID = engine.config.selectedModelID + engine.llm.refreshModelInstallStates() + selectedModelID = engine.llm.loadedModelID ?? engine.config.selectedModelID maxTokens = Double(engine.config.maxTokens) topK = Double(engine.config.topK) topP = engine.config.topP @@ -413,10 +514,9 @@ struct ConfigurationsView: View { refreshPermissionStatuses() } - private func applySettings() { + private func applySettings() -> Bool { let modelChanged = engine.config.selectedModelID != selectedModelID - engine.config.selectedModelID = selectedModelID engine.config.maxTokens = Int(maxTokens) engine.config.topK = Int(topK) engine.config.topP = topP @@ -425,9 +525,23 @@ struct ConfigurationsView: View { // 同步采样参数到 LLM(下次生成立即生效) engine.applySamplingConfig() - if modelChanged { + + guard let selectedModel = engine.availableModels.first(where: { $0.id == selectedModelID }), + engine.llm.isModelAvailable(selectedModel) else { + if let missingModel = engine.availableModels.first(where: { $0.id == selectedModelID }) { + engine.llm.statusMessage = localized("请先在配置中下载 ", "Please download ") + + missingModel.displayName + + localized(" 模型", " first") + } + return false + } + + engine.config.selectedModelID = selectedModelID + let needsLoad = !engine.llm.isLoaded || engine.llm.loadedModelID != selectedModelID + if modelChanged || needsLoad { engine.reloadModel() } + return true } private func refreshPermissionStatuses() { @@ -452,3 +566,12 @@ struct ConfigurationsView: View { #endif } } + +private extension ModelInstallState { + var isFailure: Bool { + if case .failed = self { + return true + } + return false + } +}