Stabilize local skills and keep shell install default
This commit is contained in:
1 parent
63781c32c3
commit
c81625aff8
6 files changed
+1451
-97
No files matched your search
+437
-20
@@ -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
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user