refactor: split MLX service, introduce skill type & runtime budgets
Major restructuring of the agent stack to support content-type skills, data-driven runtime budgets, and the new translate skill. LLM/MLX/ - Split MLXLocalLLMService.swift (1500 → 700 lines) into Core/, Gemma4/, Installation/ subdirectories - New BundledModel: LinearBudgetFormula replaces tier tables, model parameters fully data-driven via ModelRuntimeProfile - New RuntimeBudgets: pure functions for text/thinking/multimodal budgets, zero hardcoded model IDs - Drop adjustedTextOutputTokens prepared-subtraction (chunked prefill made it obsolete and triggered false-positive truncation on long prompts) Skills/ - Add SkillType (.device / .content) metadata, parsed from SKILL.md frontmatter - New translate skill (type: content, no tools) with anaphora handling - Trim translate (4563→1804), device (2357→1680), calendar (2169→1790) - contacts SKILL.md adds multi-turn disambiguation + cancel guidance, semantics owned by body not framework - SkillRegistry validates allowed-tools against ToolRegistry at load time Agent/ - ToolCallParser becomes registry-aware: drops tool_call names not in ToolRegistry or protocol set (load_skill / list_skills); ToolRegistry is now the single white list, validation moves to parser boundary - Router sticky routing uses semantic user-turn boundary instead of fixed suffix(4); correctly handles long agent loops - AgentEngine system prompt template differentiates device vs content skills with explicit invocation rules - SYSPROMPT.md auto-migration: old single-placeholder template backed up to .bak when new dual-placeholder is detected - PromptBuilder.buildLoadedSkillPrompt receives availableTools list, explicitly tells model which tools (or none) are callable Tools/ - ToolRegistry split into Helpers/Stores/AppPermissions/Handlers/* for separation of concerns - Mark Tools/Handlers/* with explicit allowed argument signatures UI/ - Add long-press copy contextMenu on user and assistant bubbles - Enable .textSelection on user bubble Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8872646858
commit
779be78843
58 files changed
+7031
-5687
No files matched your search
@@ -0,0 +1,186 @@
|
||||
import AVFoundation
|
||||
import Contacts
|
||||
import EventKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - 权限模型
|
||||
|
||||
enum AppPermissionKind: String, CaseIterable, Identifiable {
|
||||
case microphone
|
||||
case calendar
|
||||
case reminders
|
||||
case contacts
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .microphone: return "麦克风"
|
||||
case .calendar: return "日历"
|
||||
case .reminders: return "提醒事项"
|
||||
case .contacts: return "通讯录"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .microphone: return "允许录音并采集实时音频输入"
|
||||
case .calendar: return "允许创建和写入日历事项"
|
||||
case .reminders: return "允许创建提醒和待办"
|
||||
case .contacts: return "允许查询、保存和删除联系人"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .microphone: return "mic"
|
||||
case .calendar: return "calendar"
|
||||
case .reminders: return "bell"
|
||||
case .contacts: return "person.crop.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppPermissionStatus: Equatable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case granted
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .notDetermined: return "未请求"
|
||||
case .denied: return "已拒绝"
|
||||
case .restricted: return "受限制"
|
||||
case .granted: return "已授权"
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .notDetermined: return "首次使用时会弹出系统授权框"
|
||||
case .denied: return "请到系统设置里手动开启权限"
|
||||
case .restricted: return "当前设备限制了这项权限"
|
||||
case .granted: return "可以直接执行相关 Skill"
|
||||
}
|
||||
}
|
||||
|
||||
var isGranted: Bool {
|
||||
self == .granted
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 权限查询与请求
|
||||
|
||||
extension ToolRegistry {
|
||||
|
||||
func authorizationStatus(for kind: AppPermissionKind) -> AppPermissionStatus {
|
||||
switch kind {
|
||||
case .microphone:
|
||||
switch AVAudioSession.sharedInstance().recordPermission {
|
||||
case .granted:
|
||||
return .granted
|
||||
case .denied:
|
||||
return .denied
|
||||
case .undetermined:
|
||||
return .notDetermined
|
||||
@unknown default:
|
||||
return .restricted
|
||||
}
|
||||
|
||||
case .calendar:
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
switch status {
|
||||
case .fullAccess, .writeOnly, .authorized:
|
||||
return .granted
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
@unknown default:
|
||||
return .restricted
|
||||
}
|
||||
|
||||
case .reminders:
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
return .granted
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted, .writeOnly:
|
||||
return .restricted
|
||||
@unknown default:
|
||||
return .restricted
|
||||
}
|
||||
|
||||
case .contacts:
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return .granted
|
||||
case .limited:
|
||||
return .granted
|
||||
case .notDetermined:
|
||||
return .notDetermined
|
||||
case .denied:
|
||||
return .denied
|
||||
case .restricted:
|
||||
return .restricted
|
||||
@unknown default:
|
||||
return .restricted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func allPermissionStatuses() -> [AppPermissionKind: AppPermissionStatus] {
|
||||
Dictionary(uniqueKeysWithValues: AppPermissionKind.allCases.map {
|
||||
($0, authorizationStatus(for: $0))
|
||||
})
|
||||
}
|
||||
|
||||
func requestAccess(for kind: AppPermissionKind) async throws -> Bool {
|
||||
switch kind {
|
||||
case .microphone:
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
case .calendar:
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
SystemStores.event.requestWriteOnlyAccessToEvents { granted, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .reminders:
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
SystemStores.event.requestFullAccessToReminders { granted, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .contacts:
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
SystemStores.contacts.requestAccess(for: .contacts) { granted, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import EventKit
|
||||
import Foundation
|
||||
|
||||
enum CalendarTools {
|
||||
|
||||
static func register(into registry: ToolRegistry) {
|
||||
|
||||
// ── calendar-create-event ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "calendar-create-event",
|
||||
description: "创建新的日历事项,可写入标题、开始时间、结束时间、地点和备注",
|
||||
parameters: "title: 事件标题, start: ISO 8601 开始时间, end: ISO 8601 结束时间(可选), location: 地点(可选), notes: 备注(可选)",
|
||||
// 只有 start 是硬参 (EventKit API 强制要求)。
|
||||
// title 是软参,缺失时 handler 用默认值,不强制 caller 提供。
|
||||
requiredParameters: ["start"]
|
||||
) { args in
|
||||
// title 是软参: 没传或为空时使用默认标题, 不阻断流程
|
||||
let rawTitle = (args["title"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let title = rawTitle.isEmpty ? "新日历事项" : rawTitle
|
||||
|
||||
guard let startRaw = args["start"] as? String,
|
||||
let startDate = parseISO8601Date(startRaw) else {
|
||||
return failurePayload(error: "缺少有效的 start 参数,必须是 ISO 8601 时间字符串")
|
||||
}
|
||||
|
||||
let endRaw = (args["end"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let endDate = endRaw.flatMap(parseISO8601Date) ?? startDate.addingTimeInterval(3600)
|
||||
guard endDate >= startDate else {
|
||||
return failurePayload(error: "end 不能早于 start")
|
||||
}
|
||||
|
||||
let location = (args["location"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let notes = (args["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
do {
|
||||
guard try await ToolRegistry.shared.requestAccess(for: .calendar) else {
|
||||
return failurePayload(error: "未获得日历写入权限")
|
||||
}
|
||||
|
||||
guard let calendar = writableEventCalendar() else {
|
||||
return failurePayload(error: "没有可用于新建事项的可写日历,请先在系统日历中启用或创建一个日历")
|
||||
}
|
||||
|
||||
let event = EKEvent(eventStore: SystemStores.event)
|
||||
event.calendar = calendar
|
||||
event.title = title
|
||||
event.startDate = startDate
|
||||
event.endDate = endDate
|
||||
if let location, !location.isEmpty {
|
||||
event.location = location
|
||||
}
|
||||
if let notes, !notes.isEmpty {
|
||||
event.notes = notes
|
||||
}
|
||||
|
||||
try SystemStores.event.save(event, span: .thisEvent, commit: true)
|
||||
|
||||
return successPayload(
|
||||
result: "已创建日历事项\u{201C}\(title)\u{201D},开始时间为 \(iso8601String(from: startDate))。",
|
||||
extras: [
|
||||
"eventId": event.eventIdentifier ?? "",
|
||||
"title": title,
|
||||
"start": iso8601String(from: startDate),
|
||||
"end": iso8601String(from: endDate),
|
||||
"location": location ?? "",
|
||||
"notes": notes ?? ""
|
||||
]
|
||||
)
|
||||
} catch {
|
||||
return failurePayload(error: "创建日历事项失败:\(error.localizedDescription)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func writableEventCalendar() -> EKCalendar? {
|
||||
if let calendar = SystemStores.event.defaultCalendarForNewEvents,
|
||||
calendar.allowsContentModifications {
|
||||
return calendar
|
||||
}
|
||||
|
||||
return SystemStores.event.calendars(for: .event)
|
||||
.first(where: \.allowsContentModifications)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import UIKit
|
||||
|
||||
enum ClipboardTools {
|
||||
|
||||
static func register(into registry: ToolRegistry) {
|
||||
|
||||
// ── clipboard-read ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "clipboard-read",
|
||||
description: "读取剪贴板当前内容",
|
||||
parameters: "无",
|
||||
isParameterless: true,
|
||||
skipFollowUp: true
|
||||
) { _ in
|
||||
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 = textPreview(from: urlText, maxCharacters: 500) {
|
||||
return [
|
||||
"kind": "url",
|
||||
"content": preview.preview,
|
||||
"truncated": preview.truncated
|
||||
]
|
||||
}
|
||||
|
||||
if pasteboard.hasStrings,
|
||||
let raw = pasteboard.string,
|
||||
let preview = textPreview(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)\(suffix)",
|
||||
extras: [
|
||||
"type": "text",
|
||||
"content": preview,
|
||||
"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: "剪贴板为空")
|
||||
}
|
||||
})
|
||||
|
||||
// ── clipboard-write ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "clipboard-write",
|
||||
description: "将文本写入剪贴板",
|
||||
parameters: "text: 要复制的文本内容",
|
||||
requiredParameters: ["text"],
|
||||
skipFollowUp: true
|
||||
) { args in
|
||||
guard let text = args["text"] as? String else {
|
||||
return failurePayload(error: "缺少 text 参数")
|
||||
}
|
||||
await MainActor.run { UIPasteboard.general.string = text }
|
||||
return successPayload(
|
||||
result: "已写入剪贴板,共 \(text.count) 个字符。",
|
||||
extras: ["copied_length": text.count]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func textPreview(
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import Contacts
|
||||
import Foundation
|
||||
|
||||
enum ContactsTools {
|
||||
|
||||
static func register(into registry: ToolRegistry) {
|
||||
|
||||
// ── contacts-upsert ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "contacts-upsert",
|
||||
description: "创建或更新联系人;若提供手机号则优先按手机号查重再更新",
|
||||
parameters: "name: 联系人姓名, phone: 手机号(可选), company: 公司(可选), email: 邮箱(可选), notes: 备注(可选)",
|
||||
requiredParameters: ["name"],
|
||||
aliases: ["contacts_upsert"]
|
||||
) { args in
|
||||
guard let rawName = args["name"] as? String else {
|
||||
return failurePayload(error: "缺少 name 参数")
|
||||
}
|
||||
let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else {
|
||||
return failurePayload(error: "缺少 name 参数")
|
||||
}
|
||||
|
||||
let phone = (args["phone"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let company = (args["company"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let email = (args["email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let notes = (args["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
do {
|
||||
guard try await ToolRegistry.shared.requestAccess(for: .contacts) else {
|
||||
return failurePayload(error: "未获得通讯录权限")
|
||||
}
|
||||
|
||||
let existingContact = phone.flatMap { try? findExistingContact(phone: $0) }
|
||||
let mutableContact: CNMutableContact
|
||||
let action: String
|
||||
|
||||
if let existingContact {
|
||||
mutableContact = existingContact.mutableCopy() as! CNMutableContact
|
||||
action = "updated"
|
||||
} else {
|
||||
mutableContact = CNMutableContact()
|
||||
action = "created"
|
||||
}
|
||||
|
||||
mutableContact.givenName = name
|
||||
mutableContact.familyName = ""
|
||||
|
||||
if let phone, !phone.isEmpty {
|
||||
mutableContact.phoneNumbers = [
|
||||
CNLabeledValue(
|
||||
label: CNLabelPhoneNumberMobile,
|
||||
value: CNPhoneNumber(stringValue: phone)
|
||||
)
|
||||
]
|
||||
}
|
||||
if let company, !company.isEmpty {
|
||||
mutableContact.organizationName = company
|
||||
}
|
||||
if let email, !email.isEmpty {
|
||||
mutableContact.emailAddresses = [
|
||||
CNLabeledValue(label: CNLabelWork, value: email as NSString)
|
||||
]
|
||||
}
|
||||
if let notes, !notes.isEmpty {
|
||||
mutableContact.note = notes
|
||||
}
|
||||
|
||||
let saveRequest = CNSaveRequest()
|
||||
if existingContact != nil {
|
||||
saveRequest.update(mutableContact)
|
||||
} else {
|
||||
saveRequest.add(mutableContact, toContainerWithIdentifier: nil)
|
||||
}
|
||||
try SystemStores.contacts.execute(saveRequest)
|
||||
|
||||
let actionText = action == "updated" ? "已更新" : "已创建"
|
||||
return successPayload(
|
||||
result: "\(actionText)联系人\u{201C}\(name)\u{201D}。",
|
||||
extras: [
|
||||
"action": action,
|
||||
"name": name,
|
||||
"phone": phone ?? "",
|
||||
"company": company ?? "",
|
||||
"email": email ?? "",
|
||||
"notes": notes ?? ""
|
||||
]
|
||||
)
|
||||
} catch {
|
||||
return failurePayload(error: "保存联系人失败:\(error.localizedDescription)")
|
||||
}
|
||||
})
|
||||
|
||||
// ── contacts-search ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "contacts-search",
|
||||
description: "搜索联系人,可按姓名、手机号、邮箱、identifier 或关键词查询联系方式",
|
||||
parameters: "query: 搜索关键词(可选), identifier: 联系人标识(可选), name: 姓名(可选), phone: 手机号(可选), email: 邮箱(可选)",
|
||||
requiredAnyOfParameters: ["query", "identifier", "name", "phone", "email"],
|
||||
aliases: ["contacts_search"]
|
||||
) { 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 ToolRegistry.shared.requestAccess(for: .contacts) else {
|
||||
return failurePayload(error: "未获得通讯录权限")
|
||||
}
|
||||
|
||||
let matches = Array(try searchContacts(
|
||||
identifier: identifier,
|
||||
name: name,
|
||||
phone: phone,
|
||||
email: email,
|
||||
query: query
|
||||
).prefix(5))
|
||||
|
||||
let items = matches.map(contactSummaryDictionary)
|
||||
if matches.isEmpty {
|
||||
return successPayload(
|
||||
result: "未找到匹配的联系人。",
|
||||
extras: [
|
||||
"count": 0,
|
||||
"items": items
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
let lines = matches.map(contactSummaryText)
|
||||
return successPayload(
|
||||
result: "找到 \(matches.count) 个联系人:\(lines.joined(separator: ";"))。",
|
||||
extras: [
|
||||
"count": matches.count,
|
||||
"items": items
|
||||
]
|
||||
)
|
||||
} catch {
|
||||
return failurePayload(error: "搜索联系人失败:\(error.localizedDescription)")
|
||||
}
|
||||
})
|
||||
|
||||
// ── contacts-delete ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "contacts-delete",
|
||||
description: "删除联系人,可按姓名、手机号、邮箱、identifier 或关键词匹配后删除",
|
||||
parameters: "query: 搜索关键词(可选), identifier: 联系人标识(可选), name: 姓名(可选), phone: 手机号(可选), email: 邮箱(可选)",
|
||||
requiredAnyOfParameters: ["query", "identifier", "name", "phone", "email"],
|
||||
aliases: ["contacts_delete", "contacts-delete-contact"]
|
||||
) { 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?.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 ToolRegistry.shared.requestAccess(for: .contacts) else {
|
||||
return failurePayload(error: "未获得通讯录权限")
|
||||
}
|
||||
|
||||
let matches = try 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(contactSummaryText).joined(separator: ";")
|
||||
return failurePayload(error: "匹配到多个联系人,请提供更具体的信息:\(previews)")
|
||||
}
|
||||
|
||||
let contact = matches[0]
|
||||
let mutableContact = contact.mutableCopy() as! CNMutableContact
|
||||
let saveRequest = CNSaveRequest()
|
||||
saveRequest.delete(mutableContact)
|
||||
try SystemStores.contacts.execute(saveRequest)
|
||||
|
||||
return successPayload(
|
||||
result: "已删除联系人\u{201C}\(formattedContactName(contact))\u{201D}。",
|
||||
extras: [
|
||||
"identifier": contact.identifier,
|
||||
"name": formattedContactName(contact),
|
||||
"phone": primaryPhone(contact) ?? "",
|
||||
"email": primaryEmail(contact) ?? ""
|
||||
]
|
||||
)
|
||||
} catch {
|
||||
return failurePayload(error: "删除联系人失败:\(error.localizedDescription)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func contactKeysToFetch() -> [CNKeyDescriptor] {
|
||||
[
|
||||
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
|
||||
]
|
||||
}
|
||||
|
||||
private static func findExistingContact(phone: String) throws -> CNContact? {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let predicate = CNContact.predicateForContacts(
|
||||
matching: CNPhoneNumber(stringValue: trimmed)
|
||||
)
|
||||
return try SystemStores.contacts.unifiedContacts(
|
||||
matching: predicate,
|
||||
keysToFetch: contactKeysToFetch()
|
||||
).first
|
||||
}
|
||||
|
||||
private static func allContacts() throws -> [CNContact] {
|
||||
var contacts: [CNContact] = []
|
||||
let request = CNContactFetchRequest(keysToFetch: contactKeysToFetch())
|
||||
request.sortOrder = .userDefault
|
||||
try SystemStores.contacts.enumerateContacts(with: request) { contact, _ in
|
||||
contacts.append(contact)
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
private static 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 static 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 static 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 static func primaryPhone(_ contact: CNContact) -> String? {
|
||||
contact.phoneNumbers
|
||||
.map { $0.value.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first(where: { !$0.isEmpty })
|
||||
}
|
||||
|
||||
private static func primaryEmail(_ contact: CNContact) -> String? {
|
||||
contact.emailAddresses
|
||||
.map { String($0.value).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first(where: { !$0.isEmpty })
|
||||
}
|
||||
|
||||
private static func contactSummaryDictionary(_ contact: CNContact) -> [String: Any] {
|
||||
[
|
||||
"identifier": contact.identifier,
|
||||
"name": formattedContactName(contact),
|
||||
"phone": primaryPhone(contact) ?? "",
|
||||
"company": contact.organizationName,
|
||||
"email": primaryEmail(contact) ?? ""
|
||||
]
|
||||
}
|
||||
|
||||
private static 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 static 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 SystemStores.contacts.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum DeviceTools {
|
||||
|
||||
static func register(into registry: ToolRegistry) {
|
||||
|
||||
// ── device-info ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "device-info",
|
||||
description: "使用 iOS 官方公开 API 汇总获取当前设备名称、设备类型、系统版本、内存和处理器数量",
|
||||
parameters: "无",
|
||||
isParameterless: true
|
||||
) { _ in
|
||||
let payload = await devicePayload()
|
||||
let name = payload["name"] as? String ?? ""
|
||||
let localizedModel = (payload["localized_model"] as? String)?.isEmpty == false
|
||||
? (payload["localized_model"] as? String ?? "")
|
||||
: (payload["model"] as? String ?? "")
|
||||
let systemName = payload["system_name"] as? String ?? ""
|
||||
let systemVersion = payload["system_version"] as? String ?? ""
|
||||
let memoryGB = payload["memory_gb"] as? Double ?? 0
|
||||
let processorCount = payload["processor_count"] as? Int ?? 0
|
||||
|
||||
let summary = [
|
||||
name.isEmpty ? nil : "设备名称:\(name)",
|
||||
localizedModel.isEmpty ? nil : "设备类型:\(localizedModel)",
|
||||
systemVersion.isEmpty ? nil : "系统版本:\(systemName.isEmpty ? "" : systemName + " ")\(systemVersion)",
|
||||
memoryGB > 0 ? String(format: "物理内存:%.1f GB", memoryGB) : nil,
|
||||
processorCount > 0 ? "处理器核心数:\(processorCount)" : nil
|
||||
].compactMap { $0 }.joined(separator: "\n")
|
||||
|
||||
var enriched = payload
|
||||
enriched["result"] = summary
|
||||
enriched["status"] = "succeeded"
|
||||
return jsonString(enriched)
|
||||
})
|
||||
|
||||
// ── device-name ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "device-name",
|
||||
description: "使用 UIDevice.current.name 获取当前设备名称",
|
||||
parameters: "无",
|
||||
isParameterless: true
|
||||
) { _ in
|
||||
let payload = await devicePayload()
|
||||
let name = payload["name"] as? String ?? ""
|
||||
return successPayload(
|
||||
result: "这台设备的名称是 \(name)。",
|
||||
extras: ["name": name]
|
||||
)
|
||||
})
|
||||
|
||||
// ── device-model ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "device-model",
|
||||
description: "使用 UIDevice.current.model 和 localizedModel 获取当前设备类型",
|
||||
parameters: "无",
|
||||
isParameterless: true
|
||||
) { _ in
|
||||
let payload = await devicePayload()
|
||||
let model = payload["model"] as? String ?? ""
|
||||
let localizedModel = payload["localized_model"] as? String ?? ""
|
||||
return jsonString([
|
||||
"success": true,
|
||||
"status": "succeeded",
|
||||
"result": "这台设备的官方设备类型是 \((localizedModel.isEmpty ? model : localizedModel))。",
|
||||
"model": model,
|
||||
"localized_model": localizedModel
|
||||
])
|
||||
})
|
||||
|
||||
// ── device-system-version ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "device-system-version",
|
||||
description: "使用 UIDevice.current.systemName 和 systemVersion 获取系统版本",
|
||||
parameters: "无",
|
||||
isParameterless: true
|
||||
) { _ in
|
||||
let payload = await devicePayload()
|
||||
let systemName = payload["system_name"] as? String ?? ""
|
||||
let systemVersion = payload["system_version"] as? String ?? ""
|
||||
return jsonString([
|
||||
"success": true,
|
||||
"status": "succeeded",
|
||||
"result": "当前系统版本是 \(systemName) \(systemVersion)。",
|
||||
"system_name": systemName,
|
||||
"system_version": systemVersion
|
||||
])
|
||||
})
|
||||
|
||||
// ── device-memory ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "device-memory",
|
||||
description: "使用 ProcessInfo.processInfo.physicalMemory 获取设备物理内存",
|
||||
parameters: "无",
|
||||
isParameterless: true
|
||||
) { _ in
|
||||
let payload = await devicePayload()
|
||||
let memoryBytes = payload["memory_bytes"] as? Double ?? 0
|
||||
let memoryGB = payload["memory_gb"] as? Double ?? 0
|
||||
return jsonString([
|
||||
"success": true,
|
||||
"status": "succeeded",
|
||||
"result": String(format: "这台设备的物理内存约为 %.1f GB。", memoryGB),
|
||||
"memory_bytes": memoryBytes,
|
||||
"memory_gb": memoryGB
|
||||
])
|
||||
})
|
||||
|
||||
// ── device-processor-count ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "device-processor-count",
|
||||
description: "使用 ProcessInfo.processInfo.processorCount 获取处理器核心数",
|
||||
parameters: "无",
|
||||
isParameterless: true
|
||||
) { _ in
|
||||
let payload = await devicePayload()
|
||||
let processorCount = payload["processor_count"] as? Int ?? 0
|
||||
return jsonString([
|
||||
"success": true,
|
||||
"status": "succeeded",
|
||||
"result": "这台设备的处理器核心数是 \(processorCount)。",
|
||||
"processor_count": processorCount
|
||||
])
|
||||
})
|
||||
|
||||
// ── device-identifier-for-vendor ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "device-identifier-for-vendor",
|
||||
description: "使用 UIDevice.current.identifierForVendor 获取当前 App 在该设备上的 vendor 标识",
|
||||
parameters: "无",
|
||||
isParameterless: true
|
||||
) { _ in
|
||||
let payload = await devicePayload()
|
||||
let identifier = payload["identifier_for_vendor"] as? String ?? ""
|
||||
return jsonString([
|
||||
"success": true,
|
||||
"status": "succeeded",
|
||||
"result": "当前 App 在这台设备上的 identifierForVendor 是 \(identifier)。",
|
||||
"identifier_for_vendor": identifier
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func devicePayload() async -> [String: Any] {
|
||||
let info = ProcessInfo.processInfo
|
||||
let device = await MainActor.run {
|
||||
(
|
||||
UIDevice.current.name,
|
||||
UIDevice.current.model,
|
||||
UIDevice.current.localizedModel,
|
||||
UIDevice.current.systemName,
|
||||
UIDevice.current.systemVersion,
|
||||
UIDevice.current.identifierForVendor?.uuidString
|
||||
)
|
||||
}
|
||||
|
||||
var payload: [String: Any] = [
|
||||
"success": true,
|
||||
"name": device.0,
|
||||
"model": device.1,
|
||||
"localized_model": device.2,
|
||||
"system_name": device.3,
|
||||
"system_version": device.4,
|
||||
"memory_bytes": Double(info.physicalMemory),
|
||||
"memory_gb": Double(info.physicalMemory) / 1_073_741_824.0,
|
||||
"processor_count": info.processorCount
|
||||
]
|
||||
|
||||
if let identifierForVendor = device.5, !identifierForVendor.isEmpty {
|
||||
payload["identifier_for_vendor"] = identifierForVendor
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import EventKit
|
||||
import Foundation
|
||||
|
||||
enum RemindersTools {
|
||||
|
||||
static func register(into registry: ToolRegistry) {
|
||||
|
||||
// ── reminders-create ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "reminders-create",
|
||||
description: "创建新的提醒事项,可写入标题、到期时间和备注",
|
||||
parameters: "title: 提醒标题, due: ISO 8601 到期时间(可选), notes: 备注(可选)",
|
||||
requiredParameters: ["title"]
|
||||
) { args in
|
||||
guard let rawTitle = args["title"] as? String else {
|
||||
return failurePayload(error: "缺少 title 参数")
|
||||
}
|
||||
let title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
return failurePayload(error: "缺少 title 参数")
|
||||
}
|
||||
|
||||
let dueRaw = (args["due"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let dueRaw, !dueRaw.isEmpty, parseISO8601Date(dueRaw) == nil {
|
||||
return failurePayload(error: "due 必须是有效的 ISO 8601 时间字符串")
|
||||
}
|
||||
|
||||
let dueDate = dueRaw.flatMap(parseISO8601Date)
|
||||
let notes = (args["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
do {
|
||||
guard try await ToolRegistry.shared.requestAccess(for: .reminders) else {
|
||||
return failurePayload(error: "未获得提醒事项权限")
|
||||
}
|
||||
|
||||
guard let calendar = try ensureWritableReminderCalendar() else {
|
||||
return failurePayload(error: "没有可用于新建提醒事项的可写列表,且无法自动创建提醒列表,请先在系统提醒事项 App 中启用或创建一个列表")
|
||||
}
|
||||
|
||||
let reminder = EKReminder(eventStore: SystemStores.event)
|
||||
reminder.calendar = calendar
|
||||
reminder.title = title
|
||||
if let dueDate {
|
||||
reminder.dueDateComponents = reminderDateComponents(from: dueDate)
|
||||
reminder.addAlarm(EKAlarm(absoluteDate: dueDate))
|
||||
}
|
||||
if let notes, !notes.isEmpty {
|
||||
reminder.notes = notes
|
||||
}
|
||||
|
||||
try SystemStores.event.save(reminder, commit: true)
|
||||
|
||||
return successPayload(
|
||||
result: dueDate != nil
|
||||
? "已创建提醒事项\u{201C}\(title)\u{201D},提醒时间为 \(iso8601String(from: dueDate!))。"
|
||||
: "已创建提醒事项\u{201C}\(title)\u{201D}。",
|
||||
extras: [
|
||||
"calendarItemId": reminder.calendarItemIdentifier,
|
||||
"title": title,
|
||||
"due": dueDate.map { iso8601String(from: $0) } ?? "",
|
||||
"notes": notes ?? ""
|
||||
]
|
||||
)
|
||||
} catch {
|
||||
return failurePayload(error: "创建提醒事项失败:\(error.localizedDescription)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func writableReminderCalendar() -> EKCalendar? {
|
||||
if let calendar = SystemStores.event.defaultCalendarForNewReminders(),
|
||||
calendar.allowsContentModifications {
|
||||
return calendar
|
||||
}
|
||||
|
||||
return SystemStores.event.calendars(for: .reminder)
|
||||
.first(where: \.allowsContentModifications)
|
||||
}
|
||||
|
||||
private static func newReminderListTitle() -> String {
|
||||
let prefersChinese = Locale.preferredLanguages.contains { $0.hasPrefix("zh") }
|
||||
return prefersChinese ? "PhoneClaw 提醒事项" : "PhoneClaw Reminders"
|
||||
}
|
||||
|
||||
private static func reminderCalendarCreationSources() -> [EKSource] {
|
||||
let existingReminderSources = Set(
|
||||
SystemStores.event.calendars(for: .reminder)
|
||||
.map(\.source.sourceIdentifier)
|
||||
)
|
||||
|
||||
func priority(for source: EKSource) -> Int? {
|
||||
switch source.sourceType {
|
||||
case .local:
|
||||
return existingReminderSources.contains(source.sourceIdentifier) ? 0 : 1
|
||||
case .mobileMe:
|
||||
return existingReminderSources.contains(source.sourceIdentifier) ? 2 : 3
|
||||
case .calDAV:
|
||||
return existingReminderSources.contains(source.sourceIdentifier) ? 4 : 5
|
||||
case .exchange:
|
||||
return existingReminderSources.contains(source.sourceIdentifier) ? 6 : 7
|
||||
case .subscribed, .birthdays:
|
||||
return nil
|
||||
@unknown default:
|
||||
return existingReminderSources.contains(source.sourceIdentifier) ? 8 : 9
|
||||
}
|
||||
}
|
||||
|
||||
let prioritizedSources: [(priority: Int, source: EKSource)] = SystemStores.event.sources.compactMap { source -> (priority: Int, source: EKSource)? in
|
||||
guard let priority = priority(for: source) else { return nil }
|
||||
return (priority, source)
|
||||
}
|
||||
|
||||
return prioritizedSources
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.priority == rhs.priority {
|
||||
return lhs.source.title.localizedCaseInsensitiveCompare(rhs.source.title) == .orderedAscending
|
||||
}
|
||||
return lhs.priority < rhs.priority
|
||||
}
|
||||
.map(\.source)
|
||||
}
|
||||
|
||||
private static func ensureWritableReminderCalendar() throws -> EKCalendar? {
|
||||
if let calendar = writableReminderCalendar() {
|
||||
return calendar
|
||||
}
|
||||
|
||||
var lastError: Error?
|
||||
for source in reminderCalendarCreationSources() {
|
||||
let reminderList = EKCalendar(for: .reminder, eventStore: SystemStores.event)
|
||||
reminderList.title = newReminderListTitle()
|
||||
reminderList.source = source
|
||||
|
||||
do {
|
||||
try SystemStores.event.saveCalendar(reminderList, commit: true)
|
||||
if reminderList.allowsContentModifications {
|
||||
return reminderList
|
||||
}
|
||||
if let saved = SystemStores.event.calendar(withIdentifier: reminderList.calendarIdentifier),
|
||||
saved.allowsContentModifications {
|
||||
return saved
|
||||
}
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let lastError {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func reminderDateComponents(from date: Date) -> DateComponents {
|
||||
Calendar.current.dateComponents(
|
||||
in: .current,
|
||||
from: date
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
enum TextTools {
|
||||
|
||||
static func register(into registry: ToolRegistry) {
|
||||
|
||||
// ── calculate-hash ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "calculate-hash",
|
||||
description: "计算文本的哈希值",
|
||||
parameters: "text: 要计算哈希的文本",
|
||||
requiredParameters: ["text"]
|
||||
) { args in
|
||||
guard let text = args["text"] as? String else {
|
||||
return failurePayload(error: "缺少 text 参数")
|
||||
}
|
||||
let hash = text.hashValue
|
||||
return successPayload(
|
||||
result: "文本\u{201C}\(text)\u{201D}的哈希值是 \(hash)。",
|
||||
extras: [
|
||||
"input": text,
|
||||
"hash": hash
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
// ── text-reverse ──
|
||||
registry.register(RegisteredTool(
|
||||
name: "text-reverse",
|
||||
description: "翻转文本",
|
||||
parameters: "text: 要翻转的文本",
|
||||
requiredParameters: ["text"]
|
||||
) { args in
|
||||
guard let text = args["text"] as? String else {
|
||||
return failurePayload(error: "缺少 text 参数")
|
||||
}
|
||||
let reversed = String(text.reversed())
|
||||
return successPayload(
|
||||
result: "翻转结果:\(reversed)",
|
||||
extras: [
|
||||
"original": text,
|
||||
"reversed": reversed
|
||||
]
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - JSON Utilities
|
||||
|
||||
func jsonEscape(_ str: String) -> String {
|
||||
str.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
.replacingOccurrences(of: "\n", with: "\\n")
|
||||
.replacingOccurrences(of: "\t", with: "\\t")
|
||||
}
|
||||
|
||||
func jsonString(_ object: Any) -> String {
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object),
|
||||
let string = String(data: data, encoding: .utf8) else {
|
||||
return "{\"success\": false, \"error\": \"JSON 编码失败\"}"
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
// MARK: - Tool Result Payloads
|
||||
|
||||
func successPayload(
|
||||
result: String,
|
||||
extras: [String: Any] = [:]
|
||||
) -> String {
|
||||
var payload = extras
|
||||
payload["success"] = true
|
||||
payload["status"] = "succeeded"
|
||||
payload["result"] = result
|
||||
return jsonString(payload)
|
||||
}
|
||||
|
||||
func failurePayload(error: String, extras: [String: Any] = [:]) -> String {
|
||||
var payload = extras
|
||||
payload["success"] = false
|
||||
payload["status"] = "failed"
|
||||
payload["error"] = error
|
||||
return jsonString(payload)
|
||||
}
|
||||
|
||||
// MARK: - Date Helpers
|
||||
|
||||
func parseISO8601Date(_ raw: String) -> Date? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let isoFormatters: [ISO8601DateFormatter] = [
|
||||
{
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.timeZone = .current
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}(),
|
||||
{
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.timeZone = .current
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter
|
||||
}()
|
||||
]
|
||||
|
||||
for formatter in isoFormatters {
|
||||
if let date = formatter.date(from: trimmed) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
let formats = [
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm",
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy-MM-dd HH:mm"
|
||||
]
|
||||
|
||||
for format in formats {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: trimmed) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func iso8601String(from date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.timeZone = .current
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Contacts
|
||||
import EventKit
|
||||
|
||||
/// 共享系统 store 单例,供所有 ToolHandler 和 AppPermissions 使用。
|
||||
/// 解耦 ToolRegistry,避免重复实例。
|
||||
enum SystemStores {
|
||||
static let event = EKEventStore()
|
||||
static let contacts = CNContactStore()
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 原生工具注册表
|
||||
//
|
||||
// 所有原生 API 封装集中注册在这里。
|
||||
// SKILL.md 通过 allowed-tools 字段引用工具名。
|
||||
|
||||
struct RegisteredTool {
|
||||
let name: String
|
||||
let description: String
|
||||
let parameters: String
|
||||
|
||||
/// 必填参数名列表,用于泛化参数校验(替代 per-tool switch)。
|
||||
/// 空数组表示无参数或无需强制校验。
|
||||
let requiredParameters: [String]
|
||||
|
||||
/// 至少要提供其中一个参数名;用于表达 search/delete 这类"多选一"契约。
|
||||
let requiredAnyOfParameters: [String]
|
||||
|
||||
/// 该工具名的别名(纯格式层面),用于泛化 canonicalToolName。
|
||||
/// 例如 ["contacts_delete", "contacts-delete-contact"]
|
||||
let aliases: [String]
|
||||
|
||||
/// 该工具是否完全无参,可直接执行。
|
||||
let isParameterless: Bool
|
||||
|
||||
/// 如果为 true,工具执行后直接使用 result 字段作为最终回答,不再走 LLM follow-up。
|
||||
let skipFollowUp: Bool
|
||||
|
||||
let execute: ([String: Any]) async throws -> String
|
||||
|
||||
init(
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: String,
|
||||
requiredParameters: [String] = [],
|
||||
requiredAnyOfParameters: [String] = [],
|
||||
aliases: [String] = [],
|
||||
isParameterless: Bool = false,
|
||||
skipFollowUp: Bool = false,
|
||||
execute: @escaping ([String: Any]) async throws -> String
|
||||
) {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parameters = parameters
|
||||
self.requiredParameters = requiredParameters
|
||||
self.requiredAnyOfParameters = requiredAnyOfParameters
|
||||
self.aliases = aliases
|
||||
self.isParameterless = isParameterless
|
||||
self.skipFollowUp = skipFollowUp
|
||||
self.execute = execute
|
||||
}
|
||||
|
||||
func validates(arguments: [String: Any]) -> Bool {
|
||||
if isParameterless {
|
||||
return arguments.isEmpty
|
||||
}
|
||||
|
||||
for parameter in requiredParameters {
|
||||
guard Self.hasMeaningfulValue(arguments[parameter]) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if !requiredAnyOfParameters.isEmpty,
|
||||
!requiredAnyOfParameters.contains(where: { Self.hasMeaningfulValue(arguments[$0]) }) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private static func hasMeaningfulValue(_ value: Any?) -> Bool {
|
||||
switch value {
|
||||
case let string as String:
|
||||
return !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
case let array as [Any]:
|
||||
return !array.isEmpty
|
||||
case let dictionary as [String: Any]:
|
||||
return !dictionary.isEmpty
|
||||
case nil:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ToolRegistry {
|
||||
static let shared = ToolRegistry()
|
||||
|
||||
private var tools: [String: RegisteredTool] = [:]
|
||||
/// alias → canonical tool name
|
||||
private var aliasIndex: [String: String] = [:]
|
||||
|
||||
private init() {
|
||||
registerBuiltInTools()
|
||||
}
|
||||
|
||||
// MARK: - 公开接口
|
||||
|
||||
func register(_ tool: RegisteredTool) {
|
||||
tools[tool.name] = tool
|
||||
for alias in tool.aliases {
|
||||
aliasIndex[alias] = tool.name
|
||||
}
|
||||
}
|
||||
|
||||
func find(name: String) -> RegisteredTool? {
|
||||
if let tool = tools[name] {
|
||||
return tool
|
||||
}
|
||||
if let resolved = canonicalName(for: name) {
|
||||
return tools[resolved]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execute(name: String, args: [String: Any]) async throws -> String {
|
||||
guard let tool = find(name: name) else {
|
||||
return "{\"success\": false, \"error\": \"未知工具: \(name)\"}"
|
||||
}
|
||||
return try await tool.execute(args)
|
||||
}
|
||||
|
||||
/// 根据名称列表获取工具(用于 SKILL.md 的 allowed-tools)
|
||||
func toolsFor(names: [String]) -> [RegisteredTool] {
|
||||
names.compactMap { find(name: $0) }
|
||||
}
|
||||
|
||||
/// 返回 true 如果该工具已注册
|
||||
func hasToolNamed(_ name: String) -> Bool {
|
||||
find(name: name) != nil
|
||||
}
|
||||
|
||||
/// 所有已注册的工具名
|
||||
var allToolNames: [String] {
|
||||
Array(tools.keys).sorted()
|
||||
}
|
||||
|
||||
// MARK: - 泛化查询(替代 AgentEngine 中的 per-tool switch)
|
||||
|
||||
/// 将别名或格式变体解析为注册的 canonical tool name。
|
||||
func canonicalName(for rawName: String) -> String? {
|
||||
let normalized = rawName
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "_", with: "-")
|
||||
.lowercased()
|
||||
if tools[normalized] != nil { return normalized }
|
||||
if let mapped = aliasIndex[normalized] { return mapped }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 某个工具的必填参数列表。
|
||||
func requiredParams(for toolName: String) -> [String] {
|
||||
find(name: toolName)?.requiredParameters ?? []
|
||||
}
|
||||
|
||||
/// 某个工具的"至少一个参数"候选列表。
|
||||
func requiredAnyOfParams(for toolName: String) -> [String] {
|
||||
find(name: toolName)?.requiredAnyOfParameters ?? []
|
||||
}
|
||||
|
||||
/// 某个工具是否为无参工具。
|
||||
func isParameterlessTool(named toolName: String) -> Bool {
|
||||
find(name: toolName)?.isParameterless ?? false
|
||||
}
|
||||
|
||||
/// 统一的参数契约校验。
|
||||
func validatesArguments(_ arguments: [String: Any], for toolName: String) -> Bool {
|
||||
find(name: toolName)?.validates(arguments: arguments) ?? false
|
||||
}
|
||||
|
||||
/// 某个工具是否声明了跳过 LLM follow-up。
|
||||
func shouldSkipFollowUp(for toolName: String) -> Bool {
|
||||
find(name: toolName)?.skipFollowUp ?? false
|
||||
}
|
||||
|
||||
// MARK: - 内置工具注册
|
||||
|
||||
private func registerBuiltInTools() {
|
||||
ClipboardTools.register(into: self)
|
||||
DeviceTools.register(into: self)
|
||||
TextTools.register(into: self)
|
||||
CalendarTools.register(into: self)
|
||||
RemindersTools.register(into: self)
|
||||
ContactsTools.register(into: self)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user