Stabilize local skills and keep shell install default

This commit is contained in:
Kellyvv committed 2026-04-06 16:05:56 +08:00
1 parent 63781c32c3
commit c81625aff8
6 files changed
+1451 -97

No files matched your search

+437 -20
View File
@@ -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[..<phoneRange.lowerBound]
let cleaned = prefix
.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: "")
.replacingOccurrences(of: "通讯录", with: "")
.replacingOccurrences(of: "的电话", with: "")
.replacingOccurrences(of: "电话", with: "")
.replacingOccurrences(of: "手机号", with: "")
.replacingOccurrences(of: "号码", with: "")
.replacingOccurrences(of: "联系方式", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleaned.isEmpty {
name = cleaned
@@ -552,6 +687,153 @@ class AgentEngine {
if let company, !company.isEmpty { result["company"] = company }
return result
case "contacts-search":
let phone = text.firstMatch(of: /1[3-9]\d{9}/).map { String($0.0) }
let email: String? = {
guard let regex = try? NSRegularExpression(
pattern: "[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}",
options: [.caseInsensitive]
) else {
return nil
}
let range = NSRange(text.startIndex..., in: text)
guard let match = regex.firstMatch(in: text, range: range),
let capture = Range(match.range, in: text) else {
return nil
}
return String(text[capture])
}()
var name: String?
let patterns = [
"(?:检查下?|查(?:一下|下)?|查询|看看)(?:联系人|通讯录)?(.+?)(?:的)?(?:电话|手机号|号码|联系方式|邮箱)",
"(?:联系人|通讯录)?(.+?)(?:的)?(?:电话|手机号|号码|联系方式|邮箱)(?:是)?(?:多少|是什么|有吗)?"
]
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)
if !value.isEmpty {
name = value
break
}
}
}
if name == 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: "")
.replacingOccurrences(of: "电话", with: "")
.replacingOccurrences(of: "手机号", with: "")
.replacingOccurrences(of: "号码", with: "")
.replacingOccurrences(of: "联系方式", with: "")
.replacingOccurrences(of: "邮箱", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleaned.isEmpty {
name = cleaned
}
}
var result: [String: Any] = [:]
if let name, !name.isEmpty { result["name"] = name }
if let phone { result["phone"] = phone }
if let email { result["email"] = email }
if result.isEmpty {
result["query"] = text
}
return result
case "contacts-delete":
let phone = text.firstMatch(of: /1[3-9]\d{9}/).map { String($0.0) }
let email: String? = {
guard let regex = try? NSRegularExpression(
pattern: "[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}",
options: [.caseInsensitive]
) else {
return nil
}
let range = NSRange(text.startIndex..., in: text)
guard let match = regex.firstMatch(in: text, range: range),
let capture = Range(match.range, in: text) else {
return nil
}
return String(text[capture])
}()
var name: String?
let patterns = [
"(?:把|将)(.+?)(?:的)?(?:电话|手机号|号码|联系方式)?(?:从)?(?:联系人|通讯录)?(?:里|中)?(?:删除|删掉|删了|删吗|删|移除|去掉)",
"(?:删除|删掉|删了|删吗|删|移除|去掉)(?:联系人|通讯录)?(?:里|中)?(.+)"
]
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)
if !value.isEmpty {
name = value
break
}
}
}
if name == 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: "")
.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: "")
.replacingOccurrences(of: "移除", with: "")
.replacingOccurrences(of: "去掉", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleaned.isEmpty {
name = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: ",。,?!!? "))
}
}
var result: [String: Any] = [:]
if let name, !name.isEmpty { result["name"] = name }
if let phone { result["phone"] = phone }
if let email { result["email"] = email }
if result.isEmpty {
result["query"] = text
}
return result
case "reminders-create":
let due = detectDateInQuestion(text).map { iso8601StringForModel(from: $0) }
var title = text
@@ -624,29 +906,119 @@ class AgentEngine {
return arguments["title"] is String
case "contacts-upsert":
return arguments["name"] is String
case "contacts-search", "contacts-delete":
return arguments["query"] is String
|| arguments["identifier"] is String
|| arguments["name"] is String
|| arguments["phone"] is String
|| arguments["email"] is String
case "clipboard-write":
return arguments["text"] is String
default:
return !arguments.isEmpty
}
}
private func extractSingleToolCallForLoadedSkill(
private func shouldSkipToolFollowUpModel(for toolName: String) -> 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,
+334 -21
View File
@@ -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<Void, Never>?
private var currentGenerationTask: Task<Void, Never>?
private var currentDownloadTasks: [String: Task<Void, Never>] = [:]
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)"
}
}
}
}
+48
View File
@@ -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. 不要调用工具,不要输出 `<tool_call>`。
2. 只输出一个 JSON object,格式必须是:
{"name":"工具名","arguments":{"参数名":"参数值"}}
3. `name` 必须是上面允许的工具之一。
4. `arguments` 里只保留当前工具需要的参数;没有的可选参数直接省略。
5. 不要输出 Markdown、代码块、解释、草稿或多余文字。
6. 时间字段必须转换成 ISO 8601,例如 `2026-04-07T20:00:00`。
7. 如果缺少执行所需的关键信息,输出:
{"_needs_clarification":"请补充缺少的信息"}
<turn|>
<|turn>model
"""
}
}
+59 -14
View File
@@ -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. 工具成功后,直接用中文给出简洁结果
## 调用格式
<tool_call>
{"name": "contacts-search", "arguments": {"name": "张晓霞"}}
</tool_call>
<tool_call>
{"name": "contacts-upsert", "arguments": {"name": "王总", "phone": "13812345678", "company": "字节"}}
</tool_call>
<tool_call>
{"name": "contacts-delete", "arguments": {"name": "王总"}}
</tool_call>
"""),
]
}
+419 -11
View File
@@ -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[..<endIndex]),
truncated: endIndex < trimmed.endIndex
)
}
private func contactSearchTexts(_ contact: CNContact) -> [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)")
}
})
}
}
+154 -31
View File
@@ -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
}
}