diff --git a/.gitignore b/.gitignore
index 788aac6..68182d1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,3 +54,7 @@ LiteRT-LM/
*.dylib
*.a
*.o
+
+# ─── PhoneClawCLI (本地开发测试工具, 不入仓库) ───────────────────
+PhoneClawCLI/
+
diff --git a/Agent/AgentEngine.swift b/Agent/AgentEngine.swift
index 84d4760..be29b76 100644
--- a/Agent/AgentEngine.swift
+++ b/Agent/AgentEngine.swift
@@ -7,18 +7,6 @@ func log(_ message: String) {
print(message)
}
-private extension UserInput.Audio {
- static func from(snapshot: AudioCaptureSnapshot) -> UserInput.Audio {
- .pcm(
- .init(
- samples: snapshot.pcm,
- sampleRate: snapshot.sampleRate,
- channelCount: snapshot.channelCount
- )
- )
- }
-}
-
// MARK: - 模型/推理配置
@Observable
@@ -42,269 +30,38 @@ class ModelConfig {
private let kDefaultSystemPrompt = """
你是 PhoneClaw,一个运行在本地设备上的私人 AI 助手。你完全离线运行,不联网,保护用户隐私。
-你拥有以下能力(Skill):
+你拥有以下两类能力(Skill):
-___SKILLS___
+【设备操作类】(访问 iPhone 硬件或系统数据)
+___DEVICE_SKILLS___
-只有当用户明确要求执行某项设备内操作时,才调用 load_skill 加载该能力的详细指令。
-像"配置""信息""看看""帮我查一下"这类含糊词,不足以单独触发工具调用。
-如果用户只是普通聊天、追问上文、让你解释结果,直接回答,不要调用工具。
-如果确实需要某个能力,你必须自己调用 load_skill,不要让用户去"使用某个能力"或"打开某个 skill"。
+【内容处理类】(对文字做变换:翻译/总结/改写 等)
+___CONTENT_SKILLS___
-当且仅当确实需要某个能力时,先调用 load_skill:
+调用规则:
+
+▶ 设备操作类 skill:
+ - 只有用户明确要求执行某项设备操作时,才调用 load_skill。
+ - "配置""信息""看看""帮我查一下"这类含糊词,不足以触发。
+ - 闲聊、追问上文、解释已有结果时不调用。
+
+▶ 内容处理类 skill:
+ - 只要用户意图是对文字做该类变换(翻译/总结/改写 等),立即调用 load_skill。
+ - 即使用户用了"这段""刚才那段""上面"等指代词且没贴出源文本,也必须先调用 load_skill。
+ 加载后的指令会告诉你如何从对话历史中定位源文本。**不要**先反问用户。
+
+▶ 普通闲聊、追问设备操作结果、解释已经输出的内容:直接回答,不要调用任何 skill。
+
+调用格式:
{"name": "load_skill", "arguments": {"skill": "能力名"}}
-在已经拿到工具结果后,优先直接给出最终答案,不要无谓追问。
+加载 skill 之后请按其指令执行;拿到工具结果后优先直接给最终答案,不要无谓追问。
用中文回答,简洁实用。
"""
-// MARK: - 聊天消息
-
-struct ChatImageAttachment: Identifiable, Codable {
- let id: UUID
- let data: Data
- private static let storageMaxDimension: CGFloat = 1_024
- private static let compressionQuality: CGFloat = 0.78
-
- init(id: UUID = UUID(), data: Data) {
- self.id = id
- self.data = data
- }
-
- init?(image: UIImage) {
- let prepared = Self.preparedImage(image, maxDimension: Self.storageMaxDimension)
- if let jpeg = prepared.jpegData(compressionQuality: Self.compressionQuality) {
- self.id = UUID()
- self.data = jpeg
- } else if let png = prepared.pngData() {
- self.id = UUID()
- self.data = png
- } else {
- return nil
- }
- }
-
- static func preparedImage(_ image: UIImage, maxDimension: CGFloat = storageMaxDimension) -> UIImage {
- let originalSize = image.size
- let longestSide = max(originalSize.width, originalSize.height)
- guard longestSide > maxDimension, longestSide > 0 else {
- return image
- }
-
- let scale = maxDimension / longestSide
- let targetSize = CGSize(
- width: max(1, floor(originalSize.width * scale)),
- height: max(1, floor(originalSize.height * scale))
- )
-
- let format = UIGraphicsImageRendererFormat.default()
- format.opaque = false
- format.scale = 1
- let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
- return renderer.image { _ in
- image.draw(in: CGRect(origin: .zero, size: targetSize))
- }
- }
-
- var uiImage: UIImage? {
- UIImage(data: data)
- }
-
- var ciImage: CIImage? {
- if let image = CIImage(
- data: data,
- options: [.applyOrientationProperty: true]
- ) {
- return image
- }
- guard let uiImage else { return nil }
- if let ciImage = uiImage.ciImage {
- return ciImage
- }
- if let cgImage = uiImage.cgImage {
- return CIImage(cgImage: cgImage)
- }
- return CIImage(image: uiImage)
- }
-}
-
-struct ChatAudioAttachment: Identifiable, Codable {
- let id: UUID
- let wavData: Data
- let duration: TimeInterval
- let sampleRate: Double
- let waveform: [Float]
-
- init(
- id: UUID = UUID(),
- wavData: Data,
- duration: TimeInterval,
- sampleRate: Double,
- waveform: [Float]
- ) {
- self.id = id
- self.wavData = wavData
- self.duration = duration
- self.sampleRate = sampleRate
- self.waveform = waveform
- }
-
- init?(snapshot: AudioCaptureSnapshot) {
- guard !snapshot.pcm.isEmpty, snapshot.sampleRate > 0 else { return nil }
- self.id = UUID()
- self.wavData = Self.makeWAVData(
- pcm: snapshot.pcm,
- sampleRate: snapshot.sampleRate,
- channelCount: 1
- )
- self.duration = snapshot.duration
- self.sampleRate = snapshot.sampleRate
- self.waveform = Self.makeWaveform(from: snapshot.pcm)
- }
-
- var formattedDuration: String {
- let totalSeconds = Int(duration.rounded())
- let minutes = totalSeconds / 60
- let seconds = totalSeconds % 60
- return String(format: "%02d:%02d", minutes, seconds)
- }
-
- private static func makeWaveform(from pcm: [Float], bucketCount: Int = 36) -> [Float] {
- guard !pcm.isEmpty else { return Array(repeating: 0.12, count: bucketCount) }
- let samplesPerBucket = max(pcm.count / bucketCount, 1)
- var levels: [Float] = []
- levels.reserveCapacity(bucketCount)
-
- var index = 0
- while index < pcm.count {
- let end = min(index + samplesPerBucket, pcm.count)
- let slice = pcm[index.. bucketCount {
- levels = Array(levels.prefix(bucketCount))
- }
-
- let maxLevel = max(levels.max() ?? 0, 0.001)
- return levels.map { max($0 / maxLevel, 0.08) }
- }
-
- private static func makeWAVData(
- pcm: [Float],
- sampleRate: Double,
- channelCount: Int
- ) -> Data {
- let integerSampleRate = max(Int(sampleRate.rounded()), 1)
- let clampedSamples = pcm.map { sample -> Int16 in
- let limited = min(max(sample, -1), 1)
- return Int16((limited * Float(Int16.max)).rounded())
- }
-
- let bytesPerSample = MemoryLayout.size
- let dataChunkSize = clampedSamples.count * bytesPerSample
- let riffChunkSize = 36 + dataChunkSize
- let byteRate = integerSampleRate * channelCount * bytesPerSample
- let blockAlign = channelCount * bytesPerSample
-
- var data = Data()
- data.reserveCapacity(44 + dataChunkSize)
- data.append("RIFF".data(using: .ascii)!)
- append(UInt32(riffChunkSize), to: &data)
- data.append("WAVE".data(using: .ascii)!)
- data.append("fmt ".data(using: .ascii)!)
- append(UInt32(16), to: &data)
- append(UInt16(1), to: &data)
- append(UInt16(channelCount), to: &data)
- append(UInt32(integerSampleRate), to: &data)
- append(UInt32(byteRate), to: &data)
- append(UInt16(blockAlign), to: &data)
- append(UInt16(bytesPerSample * 8), to: &data)
- data.append("data".data(using: .ascii)!)
- append(UInt32(dataChunkSize), to: &data)
-
- for sample in clampedSamples {
- append(sample, to: &data)
- }
- return data
- }
-
- private static func append(_ value: T, to data: inout Data) {
- var littleEndian = value.littleEndian
- withUnsafeBytes(of: &littleEndian) { bytes in
- data.append(contentsOf: bytes)
- }
- }
-}
-
-struct ChatMessage: Identifiable, Codable {
- let id: UUID
- var role: Role
- var content: String
- var images: [ChatImageAttachment]
- var audios: [ChatAudioAttachment]
- let timestamp: Date
- var skillName: String? = nil
-
- init(
- id: UUID = UUID(),
- role: Role,
- content: String,
- images: [ChatImageAttachment] = [],
- audios: [ChatAudioAttachment] = [],
- timestamp: Date = Date(),
- skillName: String? = nil
- ) {
- self.id = id
- self.role = role
- self.content = content
- self.images = images
- self.audios = audios
- self.timestamp = timestamp
- self.skillName = skillName
- }
-
- mutating func update(content: String) {
- guard self.content != content else { return }
- self.content = content
- }
-
- mutating func update(role: Role, content: String, skillName: String? = nil) {
- self.role = role
- self.content = content
- self.skillName = skillName
- }
-
- enum Role: String, Codable {
- case user, assistant, system, skillResult
- }
-}
-
-struct ChatSessionSummary: Identifiable, Codable, Equatable {
- let id: UUID
- var title: String
- var preview: String
- var updatedAt: Date
-}
-
-private struct ChatSessionRecord: Codable {
- let id: UUID
- var title: String
- var preview: String
- var updatedAt: Date
- var messages: [ChatMessage]
-}
-
// MARK: - Agent Engine
@Observable
@@ -324,23 +81,23 @@ class AgentEngine {
var currentSessionID = UUID()
// 文件驱动的 Skill 系统
- let skillLoader = SkillLoader()
+ let skillRegistry = SkillRegistry()
let toolRegistry = ToolRegistry.shared
// Skill 条目(给 UI 管理用,可开关)
var skillEntries: [SkillEntry] = []
- private let thinkingOpenMarker = "[[PHONECLAW_THINK]]"
- private let thinkingCloseMarker = "[[/PHONECLAW_THINK]]"
- private let sessionsDirectoryName = "Sessions"
- private let sessionsIndexFileName = "sessions_index.json"
- private var sessionSaveTask: Task?
+ let sessionsDirectoryName = "Sessions"
+ let sessionsIndexFileName = "sessions_index.json"
+ let plannerRevision = "planner-v3-local-selection"
+ var sessionSaveTask: Task?
var enabledSkillInfos: [SkillInfo] {
skillEntries.filter(\.isEnabled).map {
SkillInfo(name: $0.id, description: $0.description,
- displayName: $0.name, icon: $0.icon, samplePrompt: $0.samplePrompt)
+ displayName: $0.name, icon: $0.icon,
+ type: $0.type, samplePrompt: $0.samplePrompt)
}
}
@@ -355,8 +112,8 @@ class AgentEngine {
?? UUID()
}
- private func loadSkillEntries() {
- let definitions = skillLoader.discoverSkills()
+ func loadSkillEntries() {
+ let definitions = skillRegistry.discoverSkills()
self.skillEntries = definitions.map { SkillEntry(from: $0, registry: toolRegistry) }
}
@@ -366,1410 +123,40 @@ class AgentEngine {
for i in skillEntries.indices {
if let wasEnabled = enabledState[skillEntries[i].id] {
skillEntries[i].isEnabled = wasEnabled
- skillLoader.setEnabled(skillEntries[i].id, enabled: wasEnabled)
+ skillRegistry.setEnabled(skillEntries[i].id, enabled: wasEnabled)
}
}
}
// MARK: - Skill 查找(文件驱动)
- private func findSkillId(for name: String) -> String? {
- let resolvedName = skillLoader.canonicalSkillId(for: name)
- if skillLoader.getDefinition(resolvedName) != nil { return resolvedName }
- return skillLoader.findSkillId(forTool: name)
+ func findSkillId(for name: String) -> String? {
+ let resolvedName = skillRegistry.canonicalSkillId(for: name)
+ if skillRegistry.getDefinition(resolvedName) != nil { return resolvedName }
+ return skillRegistry.findSkillId(forTool: name)
}
- private func findDisplayName(for name: String) -> String {
+ func findDisplayName(for name: String) -> String {
if let skillId = findSkillId(for: name),
- let def = skillLoader.getDefinition(skillId) {
+ let def = skillRegistry.getDefinition(skillId) {
return def.metadata.displayName
}
return name
}
- private func handleLoadSkill(skillName: String) -> String? {
- let resolvedSkillName = skillLoader.canonicalSkillId(for: skillName)
+ func handleLoadSkill(skillName: String) -> String? {
+ let resolvedSkillName = skillRegistry.canonicalSkillId(for: skillName)
guard let entry = skillEntries.first(where: { $0.id == resolvedSkillName }),
entry.isEnabled else {
return nil
}
- return skillLoader.loadBody(skillId: resolvedSkillName)
+ return skillRegistry.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 {
+ func handleToolExecution(toolName: String, args: [String: Any]) async throws -> String {
return try await toolRegistry.execute(name: toolName, args: args)
}
- private func autoToolCallForLoadedSkills(
- skillIds: [String]
- ) -> (name: String, arguments: [String: Any])? {
- let uniqueSkillIds = Array(NSOrderedSet(array: skillIds)) as? [String] ?? skillIds
-
- guard uniqueSkillIds.count == 1,
- let skillId = uniqueSkillIds.first,
- let def = skillLoader.getDefinition(skillId),
- def.isEnabled else {
- return nil
- }
-
- let uniqueToolNames = Array(NSOrderedSet(array: def.metadata.allowedTools)) as? [String]
- ?? def.metadata.allowedTools
- guard uniqueToolNames.count == 1,
- let toolName = uniqueToolNames.first,
- let tool = toolRegistry.find(name: toolName),
- tool.parameters == "无" else {
- return nil
- }
-
- return (tool.name, [:])
- }
-
- private func registeredTools(for skillId: String) -> [RegisteredTool] {
- if let def = skillLoader.getDefinition(skillId) {
- let tools = toolRegistry.toolsFor(names: def.metadata.allowedTools)
- if !tools.isEmpty { return tools }
- }
-
- if let entry = skillEntries.first(where: { $0.id == skillId }) {
- let tools = entry.tools.compactMap { toolRegistry.find(name: $0.name) }
- if !tools.isEmpty { return tools }
- }
-
- return []
- }
-
- private func inferFallbackToolCall(
- skillIds: [String],
- userQuestion: String
- ) -> (name: String, arguments: [String: Any])? {
- let uniqueSkillIds = Array(NSOrderedSet(array: skillIds)) as? [String] ?? skillIds
- guard uniqueSkillIds.count == 1,
- let skillId = uniqueSkillIds.first else {
- return nil
- }
-
- let tools = registeredTools(for: skillId)
- guard !tools.isEmpty else { return nil }
-
- if tools.count == 1, tools[0].parameters == "无" {
- return (tools[0].name, [:])
- }
-
- let normalizedQuestion = userQuestion.lowercased()
-
- func has(_ keyword: String) -> Bool {
- normalizedQuestion.contains(keyword)
- }
-
- let candidateNames: [String]
- switch skillId {
- case "device":
- if has("系统版本") || has("ios 版本") || has("版本号") {
- candidateNames = ["device-system-version", "device-info"]
- } else if has("名字") || has("名称") || has("叫什么") {
- candidateNames = ["device-name", "device-info"]
- } else if has("型号") || has("机型") {
- candidateNames = ["device-model", "device-info"]
- } else if has("内存") || has("ram") {
- candidateNames = ["device-memory", "device-info"]
- } else if has("处理器") || has("核心") || has("cpu") {
- candidateNames = ["device-processor-count", "device-info"]
- } 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"]
- }
- case "calendar":
- let actionKeywords = [
- "安排", "创建", "新建", "加入", "加到", "写进", "写入", "记到"
- ]
- let createIntent =
- (has("日历") || has("日程") || has("会议") || has("约会"))
- && actionKeywords.contains(where: has)
- candidateNames = createIntent ? ["calendar-create-event"] : []
- case "reminders":
- let actionKeywords = [
- "提醒", "提醒我", "记得", "待办", "创建", "新建", "添加"
- ]
- let createIntent = actionKeywords.contains(where: has)
- candidateNames = createIntent ? ["reminders-create"] : []
- case "text":
- if has("哈希") || has("hash") {
- candidateNames = ["calculate-hash"]
- } else if has("翻转") || has("反转") {
- candidateNames = ["text-reverse"]
- } else {
- candidateNames = ["calculate-hash", "text-reverse"]
- }
- default:
- candidateNames = []
- }
-
- for name in candidateNames {
- 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
- }
-
- private enum SingleToolExtractionOutcome {
- case toolCall(name: String, arguments: [String: Any])
- case needsClarification(String)
- case failed
- }
-
- private func singleRegisteredToolForLoadedSkills(skillIds: [String]) -> RegisteredTool? {
- let uniqueSkillIds = Array(NSOrderedSet(array: skillIds)) as? [String] ?? skillIds
- guard uniqueSkillIds.count == 1,
- let skillId = uniqueSkillIds.first else {
- return nil
- }
-
- let tools = registeredTools(for: skillId)
- guard tools.count == 1 else { return nil }
- return tools.first
- }
-
- private func parseJSONObject(_ text: String) -> [String: Any]? {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nil }
-
- let candidates: [String] = {
- if trimmed.hasPrefix("```json") || trimmed.hasPrefix("```") {
- let stripped = trimmed
- .replacingOccurrences(of: "```json", with: "")
- .replacingOccurrences(of: "```", with: "")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- return [stripped]
- }
-
- if let start = trimmed.firstIndex(of: "{"),
- let end = trimmed.lastIndex(of: "}"),
- start <= end {
- return [trimmed, String(trimmed[start...end])]
- }
-
- return [trimmed]
- }()
-
- for candidate in candidates {
- guard let data = candidate.data(using: .utf8),
- let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
- continue
- }
- return object
- }
-
- return nil
- }
-
- private func iso8601StringForModel(from date: Date) -> String {
- let formatter = ISO8601DateFormatter()
- formatter.timeZone = .current
- formatter.formatOptions = [.withInternetDateTime]
- return formatter.string(from: date)
- }
-
- private func chineseNumberValue(_ token: String) -> Int? {
- if let value = Int(token) { return value }
-
- let digits: [Character: Int] = [
- "零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
- "五": 5, "六": 6, "七": 7, "八": 8, "九": 9
- ]
-
- if token == "十" { return 10 }
- if token.hasPrefix("十"), let last = token.last, let digit = digits[last] {
- return 10 + digit
- }
- if token.hasSuffix("十"), let first = token.first, let digit = digits[first] {
- return digit * 10
- }
- if token.count == 2 {
- let chars = Array(token)
- if let tens = digits[chars[0]], chars[1] == "十" {
- return tens * 10
- }
- }
- if token.count == 3 {
- let chars = Array(token)
- if let tens = digits[chars[0]], chars[1] == "十", let ones = digits[chars[2]] {
- return tens * 10 + ones
- }
- }
-
- return nil
- }
-
- private func parseBasicChineseDate(from text: String) -> Date? {
- let patterns = [
- "(今天|今日|今晚|今夜|明天|明晚|后天)(?:的)?(凌晨|早上|上午|中午|下午|晚上|傍晚)?([零〇一二两三四五六七八九十\\d]{1,3})点(?:(半)|([零〇一二两三四五六七八九十\\d]{1,3})分?)?",
- "(凌晨|早上|上午|中午|下午|晚上|傍晚)([零〇一二两三四五六七八九十\\d]{1,3})点(?:(半)|([零〇一二两三四五六七八九十\\d]{1,3})分?)?"
- ]
-
- for pattern in patterns {
- guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
- let range = NSRange(text.startIndex..., in: text)
- guard let match = regex.firstMatch(in: text, range: range) else { continue }
-
- var dayToken: String?
- var periodToken: String?
- var hourToken: String?
- var hasHalf = false
- var minuteToken: String?
-
- if match.numberOfRanges >= 6 {
- if let r = Range(match.range(at: 1), in: text), !r.isEmpty {
- dayToken = String(text[r])
- }
- if let r = Range(match.range(at: 2), in: text), !r.isEmpty {
- periodToken = String(text[r])
- }
- if let r = Range(match.range(at: 3), in: text), !r.isEmpty {
- hourToken = String(text[r])
- }
- if let r = Range(match.range(at: 4), in: text), !r.isEmpty {
- hasHalf = true
- }
- if let r = Range(match.range(at: 5), in: text), !r.isEmpty {
- minuteToken = String(text[r])
- }
- }
-
- if hourToken == nil, match.numberOfRanges >= 5 {
- if let r = Range(match.range(at: 1), in: text), !r.isEmpty {
- periodToken = String(text[r])
- }
- if let r = Range(match.range(at: 2), in: text), !r.isEmpty {
- hourToken = String(text[r])
- }
- if let r = Range(match.range(at: 3), in: text), !r.isEmpty {
- hasHalf = true
- }
- if let r = Range(match.range(at: 4), in: text), !r.isEmpty {
- minuteToken = String(text[r])
- }
- }
-
- guard let hourToken,
- var hour = chineseNumberValue(hourToken) else {
- continue
- }
-
- var minute = 0
- if hasHalf {
- minute = 30
- } else if let minuteToken, let parsedMinute = chineseNumberValue(minuteToken) {
- minute = parsedMinute
- }
-
- if let periodToken {
- switch periodToken {
- case "下午", "晚上", "傍晚":
- if hour < 12 { hour += 12 }
- case "中午":
- if hour < 11 { hour += 12 }
- case "凌晨":
- if hour == 12 { hour = 0 }
- default:
- break
- }
- }
-
- var dayOffset = 0
- switch dayToken {
- case "明天", "明晚":
- dayOffset = 1
- case "后天":
- dayOffset = 2
- default:
- dayOffset = 0
- }
-
- let calendar = Calendar.current
- let baseDate = calendar.startOfDay(for: Date())
- guard let day = calendar.date(byAdding: .day, value: dayOffset, to: baseDate),
- let finalDate = calendar.date(
- bySettingHour: hour,
- minute: minute,
- second: 0,
- of: day
- ) else {
- continue
- }
- return finalDate
- }
-
- return nil
- }
-
- private func detectDateInQuestion(_ text: String) -> Date? {
- if let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.date.rawValue) {
- let range = NSRange(text.startIndex..., in: text)
- if let match = detector.firstMatch(in: text, options: [], range: range),
- let date = match.date {
- return date
- }
- }
- return parseBasicChineseDate(from: text)
- }
-
- private func heuristicArgumentsForTool(
- toolName: String,
- userQuestion: String
- ) -> [String: Any]? {
- let text = userQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !text.isEmpty else { return nil }
-
- switch toolName {
- case "calculate-hash":
- let patterns = [
- "(?:计算|求|生成|算一下|帮我算)(.+?)(?:的)?(?:哈希|hash)(?:值)?",
- "(.+?)(?:的)?(?:哈希|hash)(?:值)?(?:是多少|是什么)?"
- ]
-
- var extracted: String?
- for pattern in patterns {
- guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) 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: "hash", with: "", options: .caseInsensitive)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .trimmingCharacters(in: CharacterSet(charactersIn: "“”\"' "))
- if !cleaned.isEmpty {
- extracted = cleaned
- }
- }
-
- guard let extracted, !extracted.isEmpty else { return nil }
- return ["text": extracted]
-
- case "text-reverse":
- 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: "")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .trimmingCharacters(in: CharacterSet(charactersIn: "“”\"' "))
- if !cleaned.isEmpty {
- extracted = cleaned
- }
- }
-
- guard let extracted, !extracted.isEmpty else { return nil }
- return ["text": extracted]
-
- 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 }
- 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) {
- name = String(text[capture]).trimmingCharacters(in: .whitespacesAndNewlines)
- break
- }
- }
-
- if name == nil,
- let phone,
- let phoneRange = text.range(of: phone) {
- let prefix = text[.. Bool {
- switch toolName {
- case "calendar-create-event":
- return arguments["title"] is String && arguments["start"] is String
- case "reminders-create":
- return arguments["title"] is String
- case "calculate-hash", "text-reverse":
- return arguments["text"] 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 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)
- }
-
- if (has("哈希") || has("hash")),
- let arguments = heuristicArgumentsForTool(toolName: "calculate-hash", userQuestion: userQuestion),
- validateSingleToolArguments(toolName: "calculate-hash", arguments: arguments) {
- return syntheticToolCallText(
- name: "calculate-hash",
- arguments: arguments
- )
- }
-
- if (has("翻转") || has("反转")),
- let arguments = heuristicArgumentsForTool(toolName: "text-reverse", userQuestion: userQuestion),
- validateSingleToolArguments(toolName: "text-reverse", arguments: arguments) {
- return syntheticToolCallText(
- name: "text-reverse",
- arguments: arguments
- )
- }
-
- let mentionsClipboard = has("剪贴板") || has("clipboard")
- if mentionsClipboard {
- 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"]
- )
- }
- }
-
- let calendarIntent =
- (has("日历") || has("日程") || has("会议") || has("约会"))
- && ["安排", "创建", "新建", "加入", "加到", "记到", "写进", "写入"].contains(where: has)
- if calendarIntent {
- if let arguments = heuristicArgumentsForTool(toolName: "calendar-create-event", userQuestion: userQuestion),
- validateSingleToolArguments(toolName: "calendar-create-event", arguments: arguments) {
- return syntheticToolCallText(
- name: "calendar-create-event",
- arguments: arguments
- )
- }
- return syntheticToolCallText(
- name: "load_skill",
- arguments: ["skill": "calendar"]
- )
- }
-
- let reminderIntent = ["提醒我", "提醒", "记得", "待办", "提醒事项"].contains(where: has)
- if reminderIntent {
- if let arguments = heuristicArgumentsForTool(toolName: "reminders-create", userQuestion: userQuestion),
- validateSingleToolArguments(toolName: "reminders-create", arguments: arguments) {
- return syntheticToolCallText(
- name: "reminders-create",
- arguments: arguments
- )
- }
- return syntheticToolCallText(
- name: "load_skill",
- arguments: ["skill": "reminders"]
- )
- }
-
- return nil
- }
-
- private func extractToolCallForLoadedSkills(
- originalPrompt: String,
- userQuestion: String,
- skillInstructions: String,
- skillIds: [String],
- images: [CIImage]
- ) async -> SingleToolExtractionOutcome {
- let uniqueSkillIds = Array(NSOrderedSet(array: skillIds)) as? [String] ?? skillIds
- guard uniqueSkillIds.count == 1,
- let skillId = uniqueSkillIds.first else {
- return .failed
- }
-
- let tools = registeredTools(for: skillId)
- .filter { $0.parameters != "无" }
- guard !tools.isEmpty else {
- return .failed
- }
-
- if tools.count == 1, let tool = tools.first {
- if let heuristic = heuristicArgumentsForTool(toolName: tool.name, userQuestion: userQuestion),
- validateSingleToolArguments(toolName: tool.name, arguments: heuristic) {
- log("[Agent] load_skill heuristic 直接执行工具: \(tool.name)")
- return .toolCall(name: tool.name, arguments: heuristic)
- }
-
- 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)
- }
- }
- }
-
- 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,
- allowedToolsSummary: allowedToolsSummary,
- 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 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 = 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
- }
-
- private func markSkillsDone(_ displayNames: [String]) {
- guard !displayNames.isEmpty else { return }
- for index in messages.indices {
- guard messages[index].role == .system,
- let skillName = messages[index].skillName,
- displayNames.contains(skillName),
- messages[index].content == "identified" || messages[index].content == "loaded" else {
- continue
- }
- messages[index].update(role: .system, content: "done", skillName: skillName)
- }
- }
-
- private func looksLikeStructuredIntermediateOutput(_ text: String) -> Bool {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return false }
-
- if trimmed.hasPrefix("```json") || trimmed.hasPrefix("```") {
- return true
- }
-
- if let regex = try? NSRegularExpression(
- pattern: "\"[A-Za-z_][A-Za-z0-9_]*\"\\s*:",
- options: []
- ) {
- let matchCount = regex.numberOfMatches(
- in: trimmed,
- range: NSRange(trimmed.startIndex..., in: trimmed)
- )
- if matchCount >= 2 && !trimmed.hasPrefix("{") {
- return true
- }
- }
-
- let suspiciousFragments = [
- "tool_name\":",
- "result_for_user_name\":",
- "text_for_display\":",
- "tool_operation_success\":",
- "arguments_for_tool_no_skill\":",
- "memory_user_power_conversion\":"
- ]
- if suspiciousFragments.filter({ trimmed.contains($0) }).count >= 2 {
- return true
- }
-
- guard let data = trimmed.data(using: .utf8),
- let json = try? JSONSerialization.jsonObject(with: data) else {
- return false
- }
-
- if let dict = json as? [String: Any] {
- if dict["name"] != nil {
- return false
- }
-
- let suspiciousKeys = [
- "final_answer", "tool_call", "arguments", "device_call",
- "next_action", "action", "tool"
- ]
- return suspiciousKeys.contains { dict[$0] != nil }
- }
-
- return false
- }
-
- private func looksLikePromptEcho(_ text: String) -> Bool {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return false }
-
- if trimmed.hasPrefix("user\n") || trimmed == "user" {
- return true
- }
-
- let suspiciousPhrases = [
- "根据已加载的 Skill",
- "不要将任何关于工具、系统或该请求的描述变成 Markdown 代码或 JSON 模板",
- "如果需要,请直接调用",
- "package_name",
- "text_for_user"
- ]
-
- let hitCount = suspiciousPhrases.reduce(into: 0) { count, phrase in
- if trimmed.contains(phrase) { count += 1 }
- }
- return hitCount >= 2
- }
-
- private func syntheticToolCallText(
- name: String,
- arguments: [String: Any]
- ) -> String {
- let jsonData = try? JSONSerialization.data(withJSONObject: [
- "name": name,
- "arguments": arguments
- ])
- let jsonString = jsonData.flatMap { String(data: $0, encoding: .utf8) }
- ?? "{\"name\":\"\(name)\",\"arguments\":{}}"
- return """
-
- \(jsonString)
-
- """
- }
-
- private func parsedToolPayload(from toolResult: String) -> [String: Any]? {
- guard let data = toolResult.data(using: .utf8),
- let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
- return nil
- }
- return payload
- }
-
- private func toolResultSummaryForModel(
- toolName: String,
- toolResult: String
- ) -> String {
- let trimmed = toolResult.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return "工具 \(toolName) 已执行,但没有返回内容。" }
-
- if let payload = parsedToolPayload(from: trimmed) {
- if let success = payload["success"] as? Bool,
- !success,
- let error = payload["error"] as? String,
- !error.isEmpty {
- return "工具 \(toolName) 执行失败:\(error)"
- }
-
- if let result = payload["result"] as? String {
- let summary = result.trimmingCharacters(in: .whitespacesAndNewlines)
- if !summary.isEmpty { return summary }
- }
- }
-
- if let rendered = renderToolResultLocally(toolName: toolName, toolResult: trimmed) {
- return rendered
- }
-
- return trimmed
- }
-
- private func fallbackReplyForEmptyToolFollowUp(toolName: String, toolResult: String) -> String {
- let trimmed = toolResult.trimmingCharacters(in: .whitespacesAndNewlines)
- let summary = toolResultSummaryForModel(toolName: toolName, toolResult: trimmed)
- if !summary.isEmpty, summary != trimmed {
- return summary
- }
-
- if trimmed.isEmpty {
- return "工具 \(toolName) 已执行,但没有返回内容。"
- }
-
- return """
- 工具 \(toolName) 已执行完成,但模型没有生成最终回答。
- 工具返回结果:
- \(trimmed)
- """
- }
-
- private func renderToolResultLocally(
- toolName: String,
- toolResult: String
- ) -> String? {
- guard let payload = parsedToolPayload(from: toolResult),
- let success = payload["success"] as? Bool,
- success else {
- return nil
- }
-
- func string(_ key: String) -> String? {
- if let value = payload[key] as? String, !value.isEmpty { return value }
- return nil
- }
-
- func int(_ key: String) -> Int? {
- if let value = payload[key] as? Int { return value }
- if let value = payload[key] as? Double { return Int(value) }
- if let value = payload[key] as? String, let intValue = Int(value) { return intValue }
- return nil
- }
-
- func double(_ key: String) -> Double? {
- if let value = payload[key] as? Double { return value }
- if let value = payload[key] as? Int { return Double(value) }
- if let value = payload[key] as? String, let doubleValue = Double(value) { return doubleValue }
- return nil
- }
-
- switch toolName {
- case "device-info":
- var lines: [String] = []
- if let name = string("name") {
- lines.append("设备名称:\(name)")
- }
- if let localizedModel = string("localized_model") ?? string("model") {
- lines.append("设备类型:\(localizedModel)")
- }
- if let systemName = string("system_name"),
- let systemVersion = string("system_version") {
- lines.append("系统版本:\(systemName) \(systemVersion)")
- } else if let systemVersion = string("system_version") {
- lines.append("系统版本:\(systemVersion)")
- }
- if let memoryGB = double("memory_gb") {
- lines.append(String(format: "物理内存:%.1f GB", memoryGB))
- }
- if let processorCount = int("processor_count") {
- lines.append("处理器核心数:\(processorCount)")
- }
- return lines.isEmpty ? nil : lines.joined(separator: "\n")
-
- case "device-name":
- if let name = string("name") {
- return "这台设备的名称是 \(name)。"
- }
-
- case "device-model":
- if let localizedModel = string("localized_model") ?? string("model") {
- return "这台设备的官方设备类型是 \(localizedModel)。"
- }
-
- case "device-system-version":
- if let systemName = string("system_name"),
- let systemVersion = string("system_version") {
- return "当前系统版本是 \(systemName) \(systemVersion)。"
- }
-
- case "device-memory":
- if let memoryGB = double("memory_gb") {
- return String(format: "这台设备的物理内存约为 %.1f GB。", memoryGB)
- }
-
- case "device-processor-count":
- if let processorCount = int("processor_count") {
- return "这台设备的处理器核心数是 \(processorCount)。"
- }
-
- case "device-identifier-for-vendor":
- if let identifier = string("identifier_for_vendor") {
- return "当前 App 在这台设备上的 identifierForVendor 是 \(identifier)。"
- }
-
- case "clipboard-read":
- if let content = string("content") {
- return "剪贴板当前内容是:\(content)"
- }
-
- case "clipboard-write":
- if let copiedLength = int("copied_length") {
- return "已写入剪贴板,共 \(copiedLength) 个字符。"
- }
-
- case "text-reverse":
- if let reversed = string("reversed") {
- return "翻转结果:\(reversed)"
- }
-
- case "calculate-hash":
- if let hash = payload["hash"] {
- return "哈希值是 \(hash)。"
- }
-
- case "calendar-create-event":
- if let title = string("title"),
- let start = string("start") {
- var parts = ["已创建日历事项“\(title)”", "开始时间是 \(start)"]
- if let location = string("location") {
- parts.append("地点是 \(location)")
- }
- return parts.joined(separator: ",") + "。"
- }
-
- case "reminders-create":
- if let title = string("title") {
- if let due = string("due") {
- return "已创建提醒事项“\(title)”,提醒时间是 \(due)。"
- }
- return "已创建提醒事项“\(title)”。"
- }
-
- case "contacts-upsert":
- if let name = string("name") {
- let action = string("action") == "updated" ? "已更新" : "已创建"
- var parts = ["\(action)联系人“\(name)”"]
- if let phone = string("phone") {
- parts.append("手机号是 \(phone)")
- }
- if let company = string("company") {
- parts.append("公司是 \(company)")
- }
- 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
- }
-
- return nil
- }
-
- private func fallbackReplyForEmptySkillFollowUp(skillName: String) -> String {
- "Skill \(skillName) 已加载,但模型没有继续生成工具调用或最终回答。请重试,或把问题说得更具体一些。"
- }
-
- private func shouldUseToolingPrompt(for userQuestion: String) -> Bool {
- let normalizedQuestion = userQuestion.lowercased()
- guard !normalizedQuestion.isEmpty else { return false }
-
- let domainKeywords = [
- "日历", "提醒", "提醒事项", "通讯录", "联系人", "剪贴板",
- "设备", "系统", "相册", "照片", "蓝牙", "wifi", "电量",
- "亮度", "音量", "电话", "短信", "邮件"
- ]
- let actionKeywords = [
- "打开", "查看", "读取", "搜索", "查询", "创建",
- "新建", "添加", "保存", "修改", "删除", "复制",
- "拨打", "发送", "设置", "调高", "调低"
- ]
-
- let mentionsDomain = domainKeywords.contains { normalizedQuestion.contains($0) }
- let mentionsAction = actionKeywords.contains { normalizedQuestion.contains($0) }
- if mentionsDomain && mentionsAction {
- return true
- }
-
- for entry in skillEntries where entry.isEnabled {
- if normalizedQuestion.contains(entry.id.lowercased())
- || normalizedQuestion.contains(entry.name.lowercased())
- {
- return true
- }
-
- if entry.tools.contains(where: { normalizedQuestion.contains($0.name.lowercased()) }) {
- return true
- }
-
- if let definition = skillLoader.getDefinition(entry.id),
- definition.metadata.triggers.contains(where: { trigger in
- let normalizedTrigger = trigger.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
- return !normalizedTrigger.isEmpty && normalizedQuestion.contains(normalizedTrigger)
- })
- {
- return true
- }
- }
-
- return mentionsDomain
- }
-
// MARK: - 初始化
/// ConfigurationsView 的"Restore default"按钮使用。
@@ -1788,6 +175,8 @@ class AgentEngine {
/// 从 ApplicationSupport/PhoneClaw/SYSPROMPT.md 读取 system prompt。
/// 文件不存在时自动写入 kDefaultSystemPrompt(供用户后续编辑)。
+ /// 如果文件存在但缺少新的 ___DEVICE_SKILLS___ / ___CONTENT_SKILLS___ 占位符,
+ /// 视为旧版自动生成的模板, 备份后用新默认覆盖。
func loadSystemPrompt() {
let fm = FileManager.default
guard let supportDir = fm.urls(for: .applicationSupportDirectory,
@@ -1802,8 +191,23 @@ class AgentEngine {
if fm.fileExists(atPath: file.path),
let content = try? String(contentsOf: file, encoding: .utf8),
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- config.systemPrompt = content
- print("[Agent] SYSPROMPT loaded (\(content.count) chars)")
+
+ // 旧版模板检测: 缺新占位符且仍然包含旧扁平占位符 → 备份后覆盖
+ let hasNewPlaceholders = content.contains("___DEVICE_SKILLS___")
+ || content.contains("___CONTENT_SKILLS___")
+ let hasOldPlaceholder = content.contains("___SKILLS___")
+
+ if !hasNewPlaceholders && hasOldPlaceholder {
+ let backup = dir.appendingPathComponent("SYSPROMPT.md.bak")
+ try? fm.removeItem(at: backup)
+ try? fm.moveItem(at: file, to: backup)
+ try? kDefaultSystemPrompt.write(to: file, atomically: true, encoding: .utf8)
+ config.systemPrompt = kDefaultSystemPrompt
+ print("[Agent] SYSPROMPT migrated: 旧模板已备份到 SYSPROMPT.md.bak, 新默认已写入")
+ } else {
+ config.systemPrompt = content
+ print("[Agent] SYSPROMPT loaded (\(content.count) chars)")
+ }
} else {
try? kDefaultSystemPrompt.write(to: file, atomically: true, encoding: .utf8)
config.systemPrompt = kDefaultSystemPrompt
@@ -1890,12 +294,27 @@ class AgentEngine {
applySamplingConfig()
- let preflightToolCall = requiresMultimodal ? nil : preflightSkillLoadCall(for: normalizedText)
+ let matchedSkillIdsForTurn = requiresMultimodal ? [] : matchedSkillIds(for: normalizedText)
+ // TODO(Step 4): 接入 llm.selectedModel.supportsStructuredPlanning 后 gate Planner 入口
+ let shouldUsePlanner = !requiresMultimodal && matchedSkillIdsForTurn.count >= 2
let shouldUseFullAgentPrompt =
!requiresMultimodal
- && (preflightToolCall != nil || shouldUseToolingPrompt(for: normalizedText))
- let activeSkillInfos = shouldUseFullAgentPrompt ? enabledSkillInfos : []
+ && !shouldUsePlanner
+ && shouldUseToolingPrompt(for: normalizedText)
+ let activeSkillInfos: [SkillInfo]
+ if shouldUseFullAgentPrompt {
+ if matchedSkillIdsForTurn.isEmpty {
+ activeSkillInfos = enabledSkillInfos
+ } else {
+ let selectedIds = Set(matchedSkillIdsForTurn)
+ let matchedInfos = enabledSkillInfos.filter { selectedIds.contains($0.name) }
+ activeSkillInfos = matchedInfos.isEmpty ? enabledSkillInfos : matchedInfos
+ }
+ } else {
+ activeSkillInfos = []
+ }
let historyDepth = requiresMultimodal ? 0 : llm.safeHistoryDepth
+ let plannerHistoryDepth = shouldUsePlanner ? 0 : historyDepth
print("[MEM] safeHistoryDepth=\(historyDepth), headroom=\(llm.availableHeadroomMB) MB")
let promptImages = promptImages(historyDepth: historyDepth, currentImages: attachments)
print(
@@ -1903,6 +322,17 @@ class AgentEngine {
+ "audio=\(audioAttachment == nil ? 0 : 1)"
)
+ // [Metric] PhoneClaw 架构优化数据收集
+ // 收集窗口:2026-04-08 ~ 2026-04-22(2 周)
+ // 到期后评估 P1-b / P2 是否执行,评估完可移除此埋点
+ #warning("[Metric] PhoneClaw 架构优化埋点,2026-04-22 前评估并移除")
+ let routedPath = Self.decideRoute(
+ requiresMultimodal: requiresMultimodal,
+ shouldUsePlanner: shouldUsePlanner,
+ shouldUseFullAgentPrompt: shouldUseFullAgentPrompt
+ )
+ log("[Metric] route=\(routedPath) skills=\(matchedSkillIdsForTurn.count) model=\(llm.selectedModel.id) multimodal=\(requiresMultimodal)")
+
messages.append(ChatMessage(role: .assistant, content: "▍"))
let msgIndex = messages.count - 1
@@ -1956,7 +386,8 @@ class AgentEngine {
history: messages,
systemPrompt: config.systemPrompt,
enableThinking: config.enableThinking,
- historyDepth: historyDepth
+ historyDepth: historyDepth,
+ showListSkillsHint: matchedSkillIdsForTurn.isEmpty
)
} else {
prompt = PromptBuilder.buildLightweightTextPrompt(
@@ -1964,27 +395,44 @@ class AgentEngine {
history: messages,
systemPrompt: config.systemPrompt,
enableThinking: config.enableThinking,
- historyDepth: historyDepth
+ historyDepth: shouldUsePlanner ? plannerHistoryDepth : historyDepth
)
}
log("[Agent] text prompt mode=\(shouldUseFullAgentPrompt ? "agent" : "light"), chars=\(prompt.count), skills=\(activeSkillInfos.count)")
- if let preflightToolCall {
- log("[Agent] preflight tool path triggered")
+ if shouldUsePlanner {
+ log("[Agent] planner path triggered revision=\(plannerRevision)")
+ let plannerHandled = await executePlannedSkillChainIfPossible(
+ prompt: prompt,
+ userQuestion: normalizedText,
+ images: promptImages
+ )
+
+ if plannerHandled {
+ if messages.indices.contains(msgIndex),
+ messages[msgIndex].role == .assistant,
+ messages[msgIndex].content == "▍" {
+ messages.remove(at: msgIndex)
+ }
+ return
+ }
+
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
+ messages.append(
+ ChatMessage(
+ role: .assistant,
+ content: "⚠️ 已识别到多个 Skill,但当前无法完成编排。请补充更具体的信息后重试。"
+ )
)
+ isProcessing = false
return
}
+
var detectedToolCall = false
var buffer = ""
var bufferFlushed = false
@@ -2049,7 +497,7 @@ class AgentEngine {
// MARK: - Skill 结果后的后续推理(支持多轮工具链)
- private func streamLLM(prompt: String, images: [CIImage]) async -> String? {
+ func streamLLM(prompt: String, images: [CIImage]) async -> String? {
return await withCheckedContinuation { (continuation: CheckedContinuation) in
llm.generateStream(prompt: prompt, images: images, audios: []) { _ in
} onComplete: { result in
@@ -2065,7 +513,7 @@ class AgentEngine {
}
}
- private func streamLLM(prompt: String, msgIndex: Int, images: [CIImage]) async -> String? {
+ func streamLLM(prompt: String, msgIndex: Int, images: [CIImage]) async -> String? {
var buffer = ""
return await withCheckedContinuation { (continuation: CheckedContinuation) in
var toolCallDetected = false
@@ -2111,286 +559,6 @@ class AgentEngine {
}
}
- private func executeToolChain(
- prompt: String,
- fullText: String,
- userQuestion: String,
- images: [CIImage],
- round: Int = 1,
- maxRounds: Int = 10
- ) async {
- guard round <= maxRounds else {
- log("[Agent] 达到最大工具链轮数 \(maxRounds)")
- isProcessing = false
- return
- }
-
- 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)
- }
- isProcessing = false
- return
- }
-
- let call = (
- name: canonicalToolName(parsedCall.name, arguments: parsedCall.arguments),
- arguments: parsedCall.arguments
- )
-
- log("[Agent] Round \(round): tool_call name=\(call.name)")
-
- // ── load_skill ──
- if call.name == "load_skill" {
- let allCalls = parseAllToolCalls(fullText)
- let loadSkillCalls = allCalls.filter { $0.name == "load_skill" }
-
- var allInstructions = ""
- var loadedDisplayNames: [String] = []
- var loadedSkillIds: [String] = []
- for lsCall in loadSkillCalls {
- let requestedSkillName = (lsCall.arguments["skill"] as? String)
- ?? (lsCall.arguments["name"] as? String)
- ?? ""
- let skillName = skillLoader.canonicalSkillId(for: requestedSkillName)
- log("[Agent] load_skill: \(requestedSkillName)")
-
- let displayName = findDisplayName(for: skillName)
- loadedDisplayNames.append(displayName)
- messages.append(ChatMessage(role: .system, content: "identified", skillName: displayName))
- let cardIdx = messages.count - 1
-
- guard let instructions = handleLoadSkill(skillName: skillName) else {
- messages[cardIdx].update(role: .system, content: "done", skillName: displayName)
- continue
- }
-
- try? await Task.sleep(for: .milliseconds(300))
- messages[cardIdx].update(role: .system, content: "loaded", skillName: displayName)
- messages.append(ChatMessage(role: .skillResult, content: instructions, skillName: skillName))
- allInstructions += instructions + "\n\n"
- loadedSkillIds.append(skillName)
- }
-
- guard !allInstructions.isEmpty else {
- isProcessing = false
- return
- }
-
- if let autoCall = autoToolCallForLoadedSkills(skillIds: loadedSkillIds)
- ?? inferFallbackToolCall(skillIds: loadedSkillIds, userQuestion: userQuestion) {
- log("[Agent] load_skill 直接执行工具: \(autoCall.name)")
- let syntheticToolCall = syntheticToolCallText(
- name: autoCall.name,
- arguments: autoCall.arguments
- )
- await executeToolChain(
- prompt: prompt,
- fullText: syntheticToolCall,
- userQuestion: userQuestion,
- images: images,
- round: round + 1,
- maxRounds: maxRounds
- )
- return
- }
-
- let singleToolExtraction = await extractToolCallForLoadedSkills(
- originalPrompt: prompt,
- userQuestion: userQuestion,
- skillInstructions: allInstructions,
- skillIds: loadedSkillIds,
- images: images
- )
- switch singleToolExtraction {
- case .toolCall(let name, let arguments):
- log("[Agent] load_skill 参数提取后执行工具: \(name)")
- let syntheticToolCall = syntheticToolCallText(name: name, arguments: arguments)
- await executeToolChain(
- prompt: prompt,
- fullText: syntheticToolCall,
- userQuestion: userQuestion,
- images: images,
- round: round + 1,
- maxRounds: maxRounds
- )
- return
-
- case .needsClarification(let clarification):
- messages.append(ChatMessage(role: .assistant, content: clarification))
- markSkillsDone(loadedDisplayNames)
- isProcessing = false
- return
-
- case .failed:
- break
- }
-
- let followUpPrompt = PromptBuilder.buildLoadedSkillPrompt(
- originalPrompt: prompt,
- userQuestion: userQuestion,
- skillInstructions: allInstructions,
- currentImageCount: images.count
- )
-
- messages.append(ChatMessage(role: .assistant, content: "▍"))
- let followUpIndex = messages.count - 1
-
- guard let nextText = await streamLLM(prompt: followUpPrompt, msgIndex: followUpIndex, images: images) else {
- isProcessing = false
- return
- }
-
- if parseToolCall(nextText) != nil {
- log("[Agent] load_skill 后检测到 tool 调用 (round \(round + 1))")
- messages[followUpIndex].update(content: "")
- await executeToolChain(
- prompt: followUpPrompt, fullText: nextText,
- userQuestion: userQuestion, images: images, round: round + 1, maxRounds: maxRounds
- )
- } else {
- let cleaned = cleanOutput(nextText)
- if cleaned.isEmpty
- || looksLikeStructuredIntermediateOutput(cleaned)
- || looksLikePromptEcho(cleaned) {
- let retryPrompt = PromptBuilder.buildLoadedSkillPrompt(
- originalPrompt: prompt,
- userQuestion: userQuestion,
- skillInstructions: allInstructions,
- currentImageCount: images.count,
- forceResponse: true
- )
-
- guard let retryText = await streamLLM(prompt: retryPrompt, msgIndex: followUpIndex, images: images) else {
- isProcessing = false
- return
- }
-
- if parseToolCall(retryText) != nil {
- log("[Agent] load_skill 重试后检测到 tool 调用 (round \(round + 1))")
- messages[followUpIndex].update(content: "")
- await executeToolChain(
- prompt: retryPrompt,
- fullText: retryText,
- userQuestion: userQuestion,
- images: images,
- round: round + 1,
- maxRounds: maxRounds
- )
- } else {
- let retryCleaned = cleanOutput(retryText)
- let loadedSkillName = loadedDisplayNames.joined(separator: ", ").isEmpty
- ? "已加载的能力"
- : loadedDisplayNames.joined(separator: ", ")
- let finalReply = retryCleaned.isEmpty
- || looksLikeStructuredIntermediateOutput(retryCleaned)
- || looksLikePromptEcho(retryCleaned)
- ? fallbackReplyForEmptySkillFollowUp(skillName: loadedSkillName)
- : retryCleaned
- messages[followUpIndex].update(content: finalReply)
- markSkillsDone(loadedDisplayNames)
- isProcessing = false
- }
- } else {
- messages[followUpIndex].update(content: cleaned)
- markSkillsDone(loadedDisplayNames)
- isProcessing = false
- }
- }
- return
- }
-
- // ── 具体 Tool 调用 ──
-
- let ownerSkillId = findSkillId(for: call.name)
- let displayName = findDisplayName(for: call.name)
-
- let cardIndex: Int
- if let idx = messages.lastIndex(where: {
- $0.role == .system && ($0.skillName == displayName || $0.skillName == call.name)
- && ($0.content == "identified" || $0.content == "loaded")
- }) {
- cardIndex = idx
- } else {
- messages.append(ChatMessage(role: .system, content: "identified", skillName: displayName))
- cardIndex = messages.count - 1
- }
-
- guard ownerSkillId != nil else {
- messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
- messages.append(ChatMessage(role: .assistant, content: "⚠️ 未知工具: \(call.name)"))
- isProcessing = false
- return
- }
-
- let enabledIds = Set(skillEntries.filter(\.isEnabled).map(\.id))
- guard enabledIds.contains(ownerSkillId!) else {
- messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
- messages.append(ChatMessage(role: .assistant, content: "⚠️ Skill \(displayName) 未启用"))
- isProcessing = false
- return
- }
-
- messages[cardIndex].update(role: .system, content: "executing:\(call.name)", skillName: displayName)
-
- do {
- let toolResult = try await handleToolExecution(toolName: call.name, args: call.arguments)
- let toolResultSummary = toolResultSummaryForModel(toolName: call.name, toolResult: toolResult)
- messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
- 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,
- toolResultSummary: toolResultSummary,
- userQuestion: userQuestion,
- currentImageCount: images.count
- )
-
- messages.append(ChatMessage(role: .assistant, content: "▍"))
- let followUpIndex = messages.count - 1
-
- guard let nextText = await streamLLM(prompt: followUpPrompt, msgIndex: followUpIndex, images: images) else {
- isProcessing = false
- return
- }
-
- if !parseAllToolCalls(nextText).isEmpty {
- log("[Agent] 检测到第 \(round + 1) 轮工具调用")
- messages[followUpIndex].update(content: "")
- await executeToolChain(
- prompt: followUpPrompt, fullText: nextText,
- userQuestion: userQuestion, images: images, round: round + 1, maxRounds: maxRounds
- )
- } else {
- let cleaned = cleanOutput(nextText)
- if cleaned.isEmpty
- || looksLikeStructuredIntermediateOutput(cleaned)
- || looksLikePromptEcho(cleaned) {
- messages[followUpIndex].update(content: fallbackReplyForEmptyToolFollowUp(
- toolName: call.name,
- toolResult: toolResult
- ))
- } else {
- messages[followUpIndex].update(content: cleaned)
- }
- isProcessing = false
- }
- } catch {
- messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
- messages.append(ChatMessage(role: .system, content: "❌ Tool 执行失败: \(error)"))
- isProcessing = false
- }
- }
-
// MARK: - 工具
func clearMessages() {
@@ -2489,228 +657,6 @@ class AgentEngine {
// MARK: - 解析
- private func parseToolCall(_ text: String) -> (name: String, arguments: [String: Any])? {
- return parseAllToolCalls(text).first
- }
-
- private func parseAllToolCalls(_ text: String) -> [(name: String, arguments: [String: Any])] {
- var results: [(name: String, arguments: [String: Any])] = []
- let patterns = [
- "\\s*(\\{.*?\\})\\s*",
- "```json\\s*(\\{.*?\\})\\s*```",
- "\\s*(\\{.*?\\})\\s*"
- ]
- for pattern in patterns {
- guard let regex = try? NSRegularExpression(pattern: pattern, options: .dotMatchesLineSeparators) else { continue }
- let range = NSRange(text.startIndex..., in: text)
- let matches = regex.matches(in: text, range: range)
- for match in matches {
- if let jsonRange = Range(match.range(at: 1), in: text) {
- let json = String(text[jsonRange])
- if let data = json.data(using: .utf8),
- let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- let name = dict["name"] as? String {
- results.append((name, dict["arguments"] as? [String: Any] ?? [:]))
- }
- }
- }
- if !results.isEmpty { break }
- }
- return results
- }
-
- // MARK: - Session Persistence
-
- private func scheduleSessionSave() {
- sessionSaveTask?.cancel()
- let currentID = currentSessionID
- sessionSaveTask = Task { [weak self] in
- try? await Task.sleep(for: .milliseconds(350))
- guard !Task.isCancelled, let self, self.currentSessionID == currentID else { return }
- self.saveCurrentSession()
- }
- }
-
- private func saveCurrentSession() {
- guard !messages.isEmpty else { return }
-
- let summary = makeSessionSummary(
- id: currentSessionID,
- messages: messages
- )
- let record = ChatSessionRecord(
- id: currentSessionID,
- title: summary.title,
- preview: summary.preview,
- updatedAt: summary.updatedAt,
- messages: messages
- )
-
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.sortedKeys]
- encoder.dateEncodingStrategy = .iso8601
-
- do {
- let directory = try ensureSessionsDirectory()
- let data = try encoder.encode(record)
- try data.write(to: sessionFileURL(for: currentSessionID), options: .atomic)
- updateSessionSummary(summary)
- persistSessionsIndex()
- UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
- _ = directory
- } catch {
- log("[History] save failed: \(error.localizedDescription)")
- }
- }
-
- private func loadPersistedSessions() {
- do {
- _ = try ensureSessionsDirectory()
- } catch {
- log("[History] setup failed: \(error.localizedDescription)")
- }
-
- sessionSummaries = loadSessionsIndex().sorted { $0.updatedAt > $1.updatedAt }
-
- if let record = loadSessionRecord(id: currentSessionID) {
- messages = record.messages
- updateSessionSummary(
- .init(
- id: record.id,
- title: record.title,
- preview: record.preview,
- updatedAt: record.updatedAt
- )
- )
- return
- }
-
- if let first = sessionSummaries.first, let record = loadSessionRecord(id: first.id) {
- currentSessionID = first.id
- UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
- messages = record.messages
- return
- }
-
- currentSessionID = UUID()
- UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
- messages = []
- }
-
- private func makeSessionSummary(id: UUID, messages: [ChatMessage]) -> ChatSessionSummary {
- let firstUser = messages.first(where: { $0.role == .user })
- let lastMeaningful = messages.reversed().first(where: { msg in
- if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- return true
- }
- return !msg.images.isEmpty || !msg.audios.isEmpty
- })
-
- let title = sessionTitle(from: firstUser)
- let preview = sessionPreview(from: lastMeaningful ?? firstUser)
- let updatedAt = (lastMeaningful ?? firstUser)?.timestamp ?? Date()
-
- return ChatSessionSummary(
- id: id,
- title: title,
- preview: preview,
- updatedAt: updatedAt
- )
- }
-
- private func sessionTitle(from message: ChatMessage?) -> String {
- guard let message else { return "新会话" }
- let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
- if !trimmed.isEmpty {
- return String(trimmed.prefix(24))
- }
- if !message.images.isEmpty && !message.audios.isEmpty {
- return "图片与语音会话"
- }
- if !message.images.isEmpty {
- return "图片会话"
- }
- if !message.audios.isEmpty {
- return "语音会话"
- }
- return "新会话"
- }
-
- private func sessionPreview(from message: ChatMessage?) -> String {
- guard let message else { return "暂无内容" }
- let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
- if !trimmed.isEmpty {
- return String(trimmed.prefix(80))
- }
- if !message.images.isEmpty && !message.audios.isEmpty {
- return "包含图片与语音"
- }
- if !message.images.isEmpty {
- return "包含图片"
- }
- if !message.audios.isEmpty {
- return "包含语音"
- }
- return "暂无内容"
- }
-
- private func updateSessionSummary(_ summary: ChatSessionSummary) {
- if let index = sessionSummaries.firstIndex(where: { $0.id == summary.id }) {
- sessionSummaries[index] = summary
- } else {
- sessionSummaries.append(summary)
- }
- sessionSummaries.sort { $0.updatedAt > $1.updatedAt }
- }
-
- private func persistSessionsIndex() {
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.sortedKeys]
- encoder.dateEncodingStrategy = .iso8601
- do {
- let data = try encoder.encode(sessionSummaries)
- try data.write(to: sessionsIndexURL(), options: .atomic)
- } catch {
- log("[History] index save failed: \(error.localizedDescription)")
- }
- }
-
- private func loadSessionsIndex() -> [ChatSessionSummary] {
- let url = sessionsIndexURL()
- guard let data = try? Data(contentsOf: url) else { return [] }
- let decoder = JSONDecoder()
- decoder.dateDecodingStrategy = .iso8601
- return (try? decoder.decode([ChatSessionSummary].self, from: data)) ?? []
- }
-
- private func loadSessionRecord(id: UUID) -> ChatSessionRecord? {
- let url = sessionFileURL(for: id)
- guard let data = try? Data(contentsOf: url) else { return nil }
- let decoder = JSONDecoder()
- decoder.dateDecodingStrategy = .iso8601
- return try? decoder.decode(ChatSessionRecord.self, from: data)
- }
-
- private func ensureSessionsDirectory() throws -> URL {
- let directory = sessionsDirectoryURL()
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- return directory
- }
-
- private func sessionsIndexURL() -> URL {
- sessionsDirectoryURL().appendingPathComponent(sessionsIndexFileName)
- }
-
- private func sessionFileURL(for id: UUID) -> URL {
- sessionsDirectoryURL().appendingPathComponent("\(id.uuidString).json")
- }
-
- private func sessionsDirectoryURL() -> URL {
- let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
- let appDir = supportDir.appendingPathComponent("PhoneClaw", isDirectory: true)
- return appDir.appendingPathComponent(sessionsDirectoryName, isDirectory: true)
- }
-
private func extractSkillName(from text: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: "\"name\"\\s*:\\s*\"([^\"]+)\""),
let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
@@ -2720,182 +666,4 @@ class AgentEngine {
return String(text[nameRange])
}
- private func cleanOutputStreaming(_ text: String) -> String {
- var result = preserveThinkingChannels(in: text)
-
- if let tcRange = result.range(of: "") {
- result = String(result[result.startIndex..", "", ""]
- for pat in endPatterns {
- if let range = result.range(of: pat) {
- result = String(result[result.startIndex...*?", options: .dotMatchesLineSeparators) {
- result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
- }
-
- result = result.replacingOccurrences(
- of: "<\\|?[/a-z_]+\\|?>",
- with: "",
- options: .regularExpression
- )
-
- if result.hasPrefix("model\n") {
- result = String(result.dropFirst(6))
- } else if result == "model" {
- return ""
- } else if result.hasPrefix("user\n") {
- result = String(result.dropFirst(5))
- } else if result == "user" {
- return ""
- }
-
- result = String(result.drop(while: { $0.isWhitespace || $0.isNewline }))
- return normalizeSafetyTruncation(in: result)
- }
-
- private func cleanOutput(_ text: String) -> String {
- var result = preserveThinkingChannels(in: text)
-
- if let regex = try? NSRegularExpression(pattern: ".*?", options: .dotMatchesLineSeparators) {
- result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
- }
-
- if let tcRange = result.range(of: "") {
- result = String(result[result.startIndex..", "", ""]
- for pat in endPatterns {
- if let range = result.range(of: pat) {
- result = String(result[result.startIndex..",
- with: "",
- options: .regularExpression
- )
-
- if let lastOpen = result.lastIndex(of: "<") {
- let tail = String(result[lastOpen...])
- let tailBody = tail.dropFirst()
- if !tailBody.isEmpty && tailBody.allSatisfy({ $0.isLetter || $0 == "_" || $0 == "/" || $0 == "|" }) {
- result = String(result[result.startIndex.. String {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard let warningRange = trimmed.range(of: "> ⚠️ ") else {
- return trimmed
- }
-
- let body = String(trimmed[.. String {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return trimmed }
-
- if let paragraphBreak = trimmed.range(of: "\n\n", options: .backwards) {
- let tailLength = trimmed.distance(from: paragraphBreak.upperBound, to: trimmed.endIndex)
- if tailLength > 0 && tailLength <= 280 {
- return String(trimmed[.. 0 && tailLength <= 220 {
- return String(trimmed[.. String.Index? {
- let sentenceEndings: Set = ["。", "!", "?", ".", "!", "?"]
- var index = text.endIndex
- while index > text.startIndex {
- index = text.index(before: index)
- if sentenceEndings.contains(text[index]) {
- return text.index(after: index)
- }
- }
- return nil
- }
-
- private func preserveThinkingChannels(in text: String) -> String {
- let openTokens = ["<|channel|>thought\n", "<|channel>thought\n"]
- let closeToken = ""
-
- var result = ""
- var cursor = text.startIndex
-
- while cursor < text.endIndex {
- let nextOpen = openTokens
- .compactMap { token -> (Range, String)? in
- guard let range = text.range(of: token, range: cursor.. UserInput.Audio {
+ .pcm(
+ .init(
+ samples: snapshot.pcm,
+ sampleRate: snapshot.sampleRate,
+ channelCount: snapshot.channelCount
+ )
+ )
+ }
+}
+
+struct ChatImageAttachment: Identifiable, Codable {
+ let id: UUID
+ let data: Data
+ private static let storageMaxDimension: CGFloat = 1_024
+ private static let compressionQuality: CGFloat = 0.78
+
+ init(id: UUID = UUID(), data: Data) {
+ self.id = id
+ self.data = data
+ }
+
+ init?(image: UIImage) {
+ let prepared = Self.preparedImage(image, maxDimension: Self.storageMaxDimension)
+ if let jpeg = prepared.jpegData(compressionQuality: Self.compressionQuality) {
+ self.id = UUID()
+ self.data = jpeg
+ } else if let png = prepared.pngData() {
+ self.id = UUID()
+ self.data = png
+ } else {
+ return nil
+ }
+ }
+
+ static func preparedImage(_ image: UIImage, maxDimension: CGFloat = storageMaxDimension) -> UIImage {
+ let originalSize = image.size
+ let longestSide = max(originalSize.width, originalSize.height)
+ guard longestSide > maxDimension, longestSide > 0 else {
+ return image
+ }
+
+ let scale = maxDimension / longestSide
+ let targetSize = CGSize(
+ width: max(1, floor(originalSize.width * scale)),
+ height: max(1, floor(originalSize.height * scale))
+ )
+
+ let format = UIGraphicsImageRendererFormat.default()
+ format.opaque = false
+ format.scale = 1
+ let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
+ return renderer.image { _ in
+ image.draw(in: CGRect(origin: .zero, size: targetSize))
+ }
+ }
+
+ var uiImage: UIImage? {
+ UIImage(data: data)
+ }
+
+ var ciImage: CIImage? {
+ if let image = CIImage(
+ data: data,
+ options: [.applyOrientationProperty: true]
+ ) {
+ return image
+ }
+ guard let uiImage else { return nil }
+ if let ciImage = uiImage.ciImage {
+ return ciImage
+ }
+ if let cgImage = uiImage.cgImage {
+ return CIImage(cgImage: cgImage)
+ }
+ return CIImage(image: uiImage)
+ }
+}
+
+struct ChatAudioAttachment: Identifiable, Codable {
+ let id: UUID
+ let wavData: Data
+ let duration: TimeInterval
+ let sampleRate: Double
+ let waveform: [Float]
+
+ init(
+ id: UUID = UUID(),
+ wavData: Data,
+ duration: TimeInterval,
+ sampleRate: Double,
+ waveform: [Float]
+ ) {
+ self.id = id
+ self.wavData = wavData
+ self.duration = duration
+ self.sampleRate = sampleRate
+ self.waveform = waveform
+ }
+
+ init?(snapshot: AudioCaptureSnapshot) {
+ guard !snapshot.pcm.isEmpty, snapshot.sampleRate > 0 else { return nil }
+ self.id = UUID()
+ self.wavData = Self.makeWAVData(
+ pcm: snapshot.pcm,
+ sampleRate: snapshot.sampleRate,
+ channelCount: 1
+ )
+ self.duration = snapshot.duration
+ self.sampleRate = snapshot.sampleRate
+ self.waveform = Self.makeWaveform(from: snapshot.pcm)
+ }
+
+ var formattedDuration: String {
+ let totalSeconds = Int(duration.rounded())
+ let minutes = totalSeconds / 60
+ let seconds = totalSeconds % 60
+ return String(format: "%02d:%02d", minutes, seconds)
+ }
+
+ private static func makeWaveform(from pcm: [Float], bucketCount: Int = 36) -> [Float] {
+ guard !pcm.isEmpty else { return Array(repeating: 0.12, count: bucketCount) }
+ let samplesPerBucket = max(pcm.count / bucketCount, 1)
+ var levels: [Float] = []
+ levels.reserveCapacity(bucketCount)
+
+ var index = 0
+ while index < pcm.count {
+ let end = min(index + samplesPerBucket, pcm.count)
+ let slice = pcm[index.. bucketCount {
+ levels = Array(levels.prefix(bucketCount))
+ }
+
+ let maxLevel = max(levels.max() ?? 0, 0.001)
+ return levels.map { max($0 / maxLevel, 0.08) }
+ }
+
+ private static func makeWAVData(
+ pcm: [Float],
+ sampleRate: Double,
+ channelCount: Int
+ ) -> Data {
+ let integerSampleRate = max(Int(sampleRate.rounded()), 1)
+ let clampedSamples = pcm.map { sample -> Int16 in
+ let limited = min(max(sample, -1), 1)
+ return Int16((limited * Float(Int16.max)).rounded())
+ }
+
+ let bytesPerSample = MemoryLayout.size
+ let dataChunkSize = clampedSamples.count * bytesPerSample
+ let riffChunkSize = 36 + dataChunkSize
+ let byteRate = integerSampleRate * channelCount * bytesPerSample
+ let blockAlign = channelCount * bytesPerSample
+
+ var data = Data()
+ data.reserveCapacity(44 + dataChunkSize)
+ data.append("RIFF".data(using: .ascii)!)
+ append(UInt32(riffChunkSize), to: &data)
+ data.append("WAVE".data(using: .ascii)!)
+ data.append("fmt ".data(using: .ascii)!)
+ append(UInt32(16), to: &data)
+ append(UInt16(1), to: &data)
+ append(UInt16(channelCount), to: &data)
+ append(UInt32(integerSampleRate), to: &data)
+ append(UInt32(byteRate), to: &data)
+ append(UInt16(blockAlign), to: &data)
+ append(UInt16(bytesPerSample * 8), to: &data)
+ data.append("data".data(using: .ascii)!)
+ append(UInt32(dataChunkSize), to: &data)
+
+ for sample in clampedSamples {
+ append(sample, to: &data)
+ }
+ return data
+ }
+
+ private static func append(_ value: T, to data: inout Data) {
+ var littleEndian = value.littleEndian
+ withUnsafeBytes(of: &littleEndian) { bytes in
+ data.append(contentsOf: bytes)
+ }
+ }
+}
diff --git a/Agent/Engine/ChatMessage.swift b/Agent/Engine/ChatMessage.swift
new file mode 100644
index 0000000..6fe7dfe
--- /dev/null
+++ b/Agent/Engine/ChatMessage.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+struct ChatMessage: Identifiable, Codable {
+ let id: UUID
+ var role: Role
+ var content: String
+ var images: [ChatImageAttachment]
+ var audios: [ChatAudioAttachment]
+ let timestamp: Date
+ var skillName: String? = nil
+
+ init(
+ id: UUID = UUID(),
+ role: Role,
+ content: String,
+ images: [ChatImageAttachment] = [],
+ audios: [ChatAudioAttachment] = [],
+ timestamp: Date = Date(),
+ skillName: String? = nil
+ ) {
+ self.id = id
+ self.role = role
+ self.content = content
+ self.images = images
+ self.audios = audios
+ self.timestamp = timestamp
+ self.skillName = skillName
+ }
+
+ mutating func update(content: String) {
+ guard self.content != content else { return }
+ self.content = content
+ }
+
+ mutating func update(role: Role, content: String, skillName: String? = nil) {
+ self.role = role
+ self.content = content
+ self.skillName = skillName
+ }
+
+ enum Role: String, Codable {
+ case user, assistant, system, skillResult
+ }
+}
+
+struct ChatSessionSummary: Identifiable, Codable, Equatable {
+ let id: UUID
+ var title: String
+ var preview: String
+ var updatedAt: Date
+}
diff --git a/Agent/Engine/OutputCleaner.swift b/Agent/Engine/OutputCleaner.swift
new file mode 100644
index 0000000..ee43558
--- /dev/null
+++ b/Agent/Engine/OutputCleaner.swift
@@ -0,0 +1,271 @@
+import Foundation
+
+// 与 PromptBuilder / ChatModels 中的常量保持一致。
+// 仅供 OutputCleaner 内部使用,外部使用方分别在自己的文件里维护副本。
+private let thinkingOpenMarker = "[[PHONECLAW_THINK]]"
+private let thinkingCloseMarker = "[[/PHONECLAW_THINK]]"
+
+extension AgentEngine {
+
+ // MARK: - 中间输出/Prompt 回声识别
+
+ func looksLikeStructuredIntermediateOutput(_ text: String) -> Bool {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return false }
+
+ if trimmed.hasPrefix("```json") || trimmed.hasPrefix("```") {
+ return true
+ }
+
+ if let regex = try? NSRegularExpression(
+ pattern: "\"[A-Za-z_][A-Za-z0-9_]*\"\\s*:",
+ options: []
+ ) {
+ let matchCount = regex.numberOfMatches(
+ in: trimmed,
+ range: NSRange(trimmed.startIndex..., in: trimmed)
+ )
+ if matchCount >= 2 && !trimmed.hasPrefix("{") {
+ return true
+ }
+ }
+
+ let suspiciousFragments = [
+ "tool_name\":",
+ "result_for_user_name\":",
+ "text_for_display\":",
+ "tool_operation_success\":",
+ "arguments_for_tool_no_skill\":",
+ "memory_user_power_conversion\":"
+ ]
+ if suspiciousFragments.filter({ trimmed.contains($0) }).count >= 2 {
+ return true
+ }
+
+ guard let data = trimmed.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) else {
+ return false
+ }
+
+ if let dict = json as? [String: Any] {
+ if dict["name"] != nil {
+ return false
+ }
+
+ let suspiciousKeys = [
+ "final_answer", "tool_call", "arguments", "device_call",
+ "next_action", "action", "tool"
+ ]
+ return suspiciousKeys.contains { dict[$0] != nil }
+ }
+
+ return false
+ }
+
+ func looksLikePromptEcho(_ text: String) -> Bool {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return false }
+
+ if trimmed.hasPrefix("user\n") || trimmed == "user" {
+ return true
+ }
+
+ let suspiciousPhrases = [
+ "根据已加载的 Skill",
+ "不要将任何关于工具、系统或该请求的描述变成 Markdown 代码或 JSON 模板",
+ "如果需要,请直接调用",
+ "package_name",
+ "text_for_user"
+ ]
+
+ let hitCount = suspiciousPhrases.reduce(into: 0) { count, phrase in
+ if trimmed.contains(phrase) { count += 1 }
+ }
+ return hitCount >= 2
+ }
+
+ // MARK: - 输出清洗
+
+ func cleanOutputStreaming(_ text: String) -> String {
+ var result = preserveThinkingChannels(in: text)
+
+ if let tcRange = result.range(of: "") {
+ result = String(result[result.startIndex..", "", ""]
+ for pat in endPatterns {
+ if let range = result.range(of: pat) {
+ result = String(result[result.startIndex...*?", options: .dotMatchesLineSeparators) {
+ result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
+ }
+
+ result = result.replacingOccurrences(
+ of: "<\\|?[/a-z_]+\\|?>",
+ with: "",
+ options: .regularExpression
+ )
+
+ if result.hasPrefix("model\n") {
+ result = String(result.dropFirst(6))
+ } else if result == "model" {
+ return ""
+ } else if result.hasPrefix("user\n") {
+ result = String(result.dropFirst(5))
+ } else if result == "user" {
+ return ""
+ }
+
+ result = String(result.drop(while: { $0.isWhitespace || $0.isNewline }))
+ return normalizeSafetyTruncation(in: result)
+ }
+
+ func cleanOutput(_ text: String) -> String {
+ var result = preserveThinkingChannels(in: text)
+
+ if let regex = try? NSRegularExpression(pattern: ".*?", options: .dotMatchesLineSeparators) {
+ result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "")
+ }
+
+ if let tcRange = result.range(of: "") {
+ result = String(result[result.startIndex..", "", ""]
+ for pat in endPatterns {
+ if let range = result.range(of: pat) {
+ result = String(result[result.startIndex..",
+ with: "",
+ options: .regularExpression
+ )
+
+ if let lastOpen = result.lastIndex(of: "<") {
+ let tail = String(result[lastOpen...])
+ let tailBody = tail.dropFirst()
+ if !tailBody.isEmpty && tailBody.allSatisfy({ $0.isLetter || $0 == "_" || $0 == "/" || $0 == "|" }) {
+ result = String(result[result.startIndex.. String {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let warningRange = trimmed.range(of: "> ⚠️ ") else {
+ return trimmed
+ }
+
+ let body = String(trimmed[.. String {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return trimmed }
+
+ if let paragraphBreak = trimmed.range(of: "\n\n", options: .backwards) {
+ let tailLength = trimmed.distance(from: paragraphBreak.upperBound, to: trimmed.endIndex)
+ if tailLength > 0 && tailLength <= 280 {
+ return String(trimmed[.. 0 && tailLength <= 220 {
+ return String(trimmed[.. String.Index? {
+ let sentenceEndings: Set = ["。", "!", "?", ".", "!", "?"]
+ var index = text.endIndex
+ while index > text.startIndex {
+ index = text.index(before: index)
+ if sentenceEndings.contains(text[index]) {
+ return text.index(after: index)
+ }
+ }
+ return nil
+ }
+
+ // MARK: - Thinking 通道保留
+
+ private func preserveThinkingChannels(in text: String) -> String {
+ let openTokens = ["<|channel|>thought\n", "<|channel>thought\n"]
+ let closeToken = ""
+
+ var result = ""
+ var cursor = text.startIndex
+
+ while cursor < text.endIndex {
+ let nextOpen = openTokens
+ .compactMap { token -> (Range, String)? in
+ guard let range = text.range(of: token, range: cursor.. String {
+ let selectedIds = uniqueStringsPreservingOrder(skillIds)
+ let chosenEntries: [SkillEntry]
+ if selectedIds.isEmpty {
+ chosenEntries = skillEntries.filter(\.isEnabled)
+ } else {
+ let selectedSet = Set(selectedIds)
+ chosenEntries = skillEntries.filter { $0.isEnabled && selectedSet.contains($0.id) }
+ }
+
+ return chosenEntries.map { entry in
+ if compact {
+ let tools = registeredTools(for: entry.id).map(\.name).joined(separator: "、")
+ return "- \(entry.id): \(tools)"
+ } else {
+ let tools = registeredTools(for: entry.id).map {
+ "\($0.name): \($0.description)"
+ }.joined(separator: ";")
+ return """
+ - \(entry.id)(\(entry.name)): \(entry.description)
+ 可用工具: \(tools)
+ """
+ }
+ }.joined(separator: "\n")
+ }
+
+ func recentPlannerContextSummary(limit: Int = 2) -> String {
+ let toolNames = Set(
+ skillEntries
+ .filter(\.isEnabled)
+ .flatMap { registeredTools(for: $0.id).map(\.name) }
+ )
+ guard !toolNames.isEmpty else { return "" }
+
+ var blocks: [String] = []
+ for message in messages.reversed() {
+ guard message.role == .skillResult,
+ let skillName = message.skillName,
+ toolNames.contains(skillName) else {
+ continue
+ }
+
+ let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continue }
+
+ let summary: String
+ if trimmed.count > 220 {
+ summary = String(trimmed.prefix(220)) + "..."
+ } else {
+ summary = trimmed
+ }
+
+ blocks.append("- \(skillName): \(summary)")
+ if blocks.count >= limit {
+ break
+ }
+ }
+
+ return blocks.reversed().joined(separator: "\n")
+ }
+
+ // MARK: - 计划解析
+
+ func parseExecutionPlan(_ text: String) -> ExecutionPlan? {
+ guard let object = parseJSONObject(text) else { return nil }
+ let goal = object["goal"] as? String ?? ""
+ let needsClarification = object["needs_clarification"] as? String
+ let rawSteps = object["steps"] as? [[String: Any]] ?? []
+
+ let steps = rawSteps.compactMap { rawStep -> ExecutionPlanStep? in
+ guard let id = rawStep["id"] as? String,
+ let skill = rawStep["skill"] as? String,
+ let tool = rawStep["tool"] as? String,
+ let intent = rawStep["intent"] as? String else {
+ return nil
+ }
+ let dependsOn = rawStep["depends_on"] as? [String] ?? []
+ return ExecutionPlanStep(
+ id: id,
+ skill: skill,
+ tool: tool,
+ intent: intent,
+ dependsOn: dependsOn
+ )
+ }
+
+ return ExecutionPlan(goal: goal, steps: steps, needsClarification: needsClarification)
+ }
+
+ func parseSkillSelection(_ text: String) -> SkillSelection? {
+ guard let object = parseJSONObject(text) else { return nil }
+ let goal = object["goal"] as? String ?? ""
+ let requiredSkills = object["required_skills"] as? [String] ?? []
+ let needsClarification = object["needs_clarification"] as? String
+ return SkillSelection(
+ goal: goal,
+ requiredSkills: requiredSkills,
+ needsClarification: needsClarification
+ )
+ }
+
+ func validateSkillSelection(
+ _ selection: SkillSelection,
+ candidateSkillIds: [String]
+ ) -> SkillSelection? {
+ let clarification = selection.needsClarification?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if let clarification, !clarification.isEmpty {
+ return SkillSelection(goal: selection.goal, requiredSkills: [], needsClarification: clarification)
+ }
+
+ let normalizedSkills = uniqueStringsPreservingOrder(
+ selection.requiredSkills.compactMap { canonicalSkillSelectionEntry($0) }
+ )
+ guard !normalizedSkills.isEmpty, normalizedSkills.count <= 3 else {
+ return nil
+ }
+
+ let enabledSkillSet = Set(skillEntries.filter(\.isEnabled).map(\.id))
+ let candidateSet = candidateSkillIds.isEmpty ? enabledSkillSet : Set(candidateSkillIds)
+ guard normalizedSkills.allSatisfy({
+ enabledSkillSet.contains($0) && (candidateSet.contains($0) || candidateSkillIds.isEmpty)
+ }) else {
+ return nil
+ }
+
+ return SkillSelection(
+ goal: selection.goal,
+ requiredSkills: normalizedSkills,
+ needsClarification: nil
+ )
+ }
+
+ func validateExecutionPlan(
+ _ plan: ExecutionPlan,
+ candidateSkillIds: [String]
+ ) -> ExecutionPlan? {
+ let clarification = plan.needsClarification?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if let clarification, !clarification.isEmpty, plan.steps.isEmpty {
+ return ExecutionPlan(goal: plan.goal, steps: [], needsClarification: clarification)
+ }
+
+ guard !plan.steps.isEmpty, plan.steps.count <= 4 else {
+ return nil
+ }
+
+ let enabledSkillSet = Set(skillEntries.filter(\.isEnabled).map(\.id))
+ let candidateSet = candidateSkillIds.isEmpty ? enabledSkillSet : Set(candidateSkillIds)
+ var seenStepIds: Set = []
+ var previousStepIds: Set = []
+
+ let uniqueSkillCount = Set(plan.steps.map(\.skill)).count
+ guard uniqueSkillCount <= 3 else { return nil }
+
+ for step in plan.steps {
+ let stepID = step.id.trimmingCharacters(in: .whitespacesAndNewlines)
+ let skillID = skillRegistry.canonicalSkillId(for: step.skill)
+ let toolName = canonicalToolName(
+ step.tool,
+ arguments: [:],
+ preferredSkillId: step.skill
+ )
+
+ guard !stepID.isEmpty,
+ !step.intent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ !seenStepIds.contains(stepID),
+ enabledSkillSet.contains(skillID),
+ candidateSet.contains(skillID) || candidateSkillIds.isEmpty else {
+ return nil
+ }
+
+ let allowedToolNames = Set(registeredTools(for: skillID).map(\.name))
+ guard allowedToolNames.contains(toolName) else {
+ return nil
+ }
+
+ guard step.dependsOn.allSatisfy({ previousStepIds.contains($0) }) else {
+ return nil
+ }
+
+ seenStepIds.insert(stepID)
+ previousStepIds.insert(stepID)
+ }
+
+ let normalizedSteps = plan.steps.map { step in
+ ExecutionPlanStep(
+ id: step.id,
+ skill: skillRegistry.canonicalSkillId(for: step.skill),
+ tool: canonicalToolName(
+ step.tool,
+ arguments: [:],
+ preferredSkillId: step.skill
+ ),
+ intent: step.intent,
+ dependsOn: step.dependsOn
+ )
+ }
+
+ return ExecutionPlan(goal: plan.goal, steps: normalizedSteps, needsClarification: nil)
+ }
+
+ func completedPlanSummary(_ completedSteps: [ExecutedPlanStep]) -> String {
+ completedSteps.map { completedStep in
+ let summary = completedStep.toolResultSummary.trimmingCharacters(in: .whitespacesAndNewlines)
+ let compactSummary: String
+ if summary.count > 160 {
+ compactSummary = String(summary.prefix(160)) + "..."
+ } else {
+ compactSummary = summary
+ }
+
+ let block = """
+ [\(completedStep.step.id)] \(completedStep.step.tool)
+ 目标:\(completedStep.step.intent)
+ 可直接给用户的结果:\(compactSummary)
+ """
+
+ return block
+ }.joined(separator: "\n\n")
+ }
+
+ // MARK: - 多 Skill 编排主入口
+
+ @discardableResult
+ func executePlannedSkillChainIfPossible(
+ prompt: String,
+ userQuestion: String,
+ images: [CIImage]
+ ) async -> Bool {
+ let matchedSkills = matchedSkillIds(for: userQuestion)
+ log("[Agent] \(plannerRevision) matchedSkills=\(matchedSkills.joined(separator: ","))")
+ let candidateSkillIds =
+ matchedSkills.count >= 2
+ ? matchedSkills
+ : skillEntries.filter(\.isEnabled).map(\.id)
+ let recentContextSummary = recentPlannerContextSummary()
+ let selectedSkillIds: [String]
+ if matchedSkills.count >= 2, matchedSkills.count <= 3 {
+ selectedSkillIds = matchedSkills
+ log("[Agent] skill selection satisfied locally skills=\(selectedSkillIds.joined(separator: ","))")
+ } else {
+ let selectionSkillsSummary = buildAvailableSkillsSummary(skillIds: candidateSkillIds)
+ guard !selectionSkillsSummary.isEmpty else {
+ messages.append(ChatMessage(role: .assistant, content: "⚠️ 当前没有可用于编排的 Skill。"))
+ isProcessing = false
+ return true
+ }
+
+ let selectionPrompt = PromptBuilder.buildSkillSelectionPrompt(
+ originalPrompt: prompt,
+ userQuestion: userQuestion,
+ availableSkillsSummary: selectionSkillsSummary,
+ recentContextSummary: recentContextSummary,
+ currentImageCount: images.count
+ )
+ log("[Agent] skill selection prompt chars=\(selectionPrompt.count), candidateSkills=\(candidateSkillIds.count)")
+
+ guard let rawSelection = await streamLLM(prompt: selectionPrompt, images: images) else {
+ messages.append(ChatMessage(role: .assistant, content: "⚠️ 无法判断需要哪些 Skill,请重试。"))
+ isProcessing = false
+ return true
+ }
+
+ let cleanedSelection = cleanOutput(rawSelection)
+ guard let parsedSelection = parseSkillSelection(cleanedSelection),
+ let validatedSelection = validateSkillSelection(parsedSelection, candidateSkillIds: candidateSkillIds) else {
+ messages.append(ChatMessage(role: .assistant, content: "⚠️ 当前无法判断需要哪些 Skill,请把需求说得更具体一些。"))
+ isProcessing = false
+ return true
+ }
+
+ if let clarification = validatedSelection.needsClarification,
+ !clarification.isEmpty {
+ messages.append(ChatMessage(role: .assistant, content: clarification))
+ isProcessing = false
+ return true
+ }
+
+ guard validatedSelection.requiredSkills.count >= 2 else {
+ messages.append(ChatMessage(role: .assistant, content: "⚠️ 当前只识别到单一 Skill,请把组合操作说得更明确一些。"))
+ isProcessing = false
+ return true
+ }
+
+ selectedSkillIds = validatedSelection.requiredSkills
+ log("[Agent] skill selection accepted skills=\(selectedSkillIds.joined(separator: ","))")
+ }
+
+ var loadedInstructions: [String: String] = [:]
+ var loadedDisplayNames: [String: String] = [:]
+ var skillCardIndices: [String: Int] = [:]
+ var completedSteps: [ExecutedPlanStep] = []
+ var toolResultsForAnswer: [(toolName: String, result: String)] = []
+ var remainingSkillIds = selectedSkillIds
+ var planningPass = 0
+
+ func finishPlanning(with message: String? = nil) {
+ if let message, !message.isEmpty {
+ messages.append(ChatMessage(role: .assistant, content: message))
+ }
+ markSkillsDone(Array(loadedDisplayNames.values))
+ isProcessing = false
+ }
+
+ while !remainingSkillIds.isEmpty, planningPass < 3 {
+ planningPass += 1
+
+ let combinedContextSummary = [recentContextSummary, completedPlanSummary(completedSteps)]
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ .joined(separator: "\n\n")
+
+ let availableSkillsSummary = buildAvailableSkillsSummary(
+ skillIds: remainingSkillIds,
+ compact: true
+ )
+ guard !availableSkillsSummary.isEmpty else { break }
+
+ let planningPrompt = PromptBuilder.buildSkillPlanningPrompt(
+ originalPrompt: prompt,
+ userQuestion: userQuestion,
+ availableSkillsSummary: availableSkillsSummary,
+ recentContextSummary: combinedContextSummary,
+ currentImageCount: images.count
+ )
+ log("[Agent] planner prompt chars=\(planningPrompt.count), candidateSkills=\(remainingSkillIds.count), pass=\(planningPass)")
+
+ guard let rawPlan = await streamLLM(prompt: planningPrompt, images: images) else {
+ let message = completedSteps.isEmpty
+ ? "⚠️ 无法生成执行计划,请重试。"
+ : "⚠️ 无法继续规划剩余步骤,请重试。"
+ finishPlanning(with: message)
+ return true
+ }
+
+ let cleanedPlan = cleanOutput(rawPlan)
+ guard let parsedPlan = parseExecutionPlan(cleanedPlan),
+ let validatedPlan = validateExecutionPlan(parsedPlan, candidateSkillIds: remainingSkillIds) else {
+ let message = completedSteps.isEmpty
+ ? "⚠️ 当前无法生成有效计划,请把需求说得更具体一些。"
+ : "⚠️ 当前无法继续规划剩余步骤,请把需求说得更具体一些。"
+ finishPlanning(with: message)
+ return true
+ }
+
+ if let clarification = validatedPlan.needsClarification,
+ !clarification.isEmpty {
+ messages.append(ChatMessage(role: .assistant, content: clarification))
+ isProcessing = false
+ return true
+ }
+
+ guard !validatedPlan.steps.isEmpty else {
+ let message = completedSteps.isEmpty
+ ? "⚠️ 当前没有可执行步骤,请补充更具体的信息。"
+ : "⚠️ 当前无法继续规划剩余步骤,请补充更具体的信息。"
+ finishPlanning(with: message)
+ return true
+ }
+
+ log("[Agent] planner accepted plan with \(validatedPlan.steps.count) steps")
+
+ let executedSkillIdsThisPass = uniqueStringsPreservingOrder(validatedPlan.steps.map(\.skill))
+
+ for step in validatedPlan.steps {
+ if loadedInstructions[step.skill] == nil {
+ let displayName = findDisplayName(for: step.skill)
+ loadedDisplayNames[step.skill] = displayName
+ messages.append(ChatMessage(role: .system, content: "identified", skillName: displayName))
+ let cardIndex = messages.count - 1
+ skillCardIndices[step.skill] = cardIndex
+
+ guard let instructions = handleLoadSkill(skillName: step.skill) else {
+ messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
+ finishPlanning(with: "⚠️ 无法加载 Skill \(displayName),已停止执行。")
+ return true
+ }
+
+ try? await Task.sleep(for: .milliseconds(300))
+ messages[cardIndex].update(role: .system, content: "loaded", skillName: displayName)
+ messages.append(ChatMessage(role: .skillResult, content: instructions, skillName: step.skill))
+ loadedInstructions[step.skill] = instructions
+ }
+
+ let displayName = loadedDisplayNames[step.skill] ?? findDisplayName(for: step.skill)
+ guard let cardIndex = skillCardIndices[step.skill],
+ let tool = toolRegistry.find(name: step.tool) else {
+ finishPlanning(with: "⚠️ 当前规划步骤无效,已停止执行。")
+ return true
+ }
+
+ let arguments: [String: Any]
+ if tool.isParameterless {
+ arguments = [:]
+ } else {
+ let completedSummary = completedPlanSummary(completedSteps)
+ let argumentsPrompt = PromptBuilder.buildPlannedToolArgumentsPrompt(
+ originalPrompt: prompt,
+ userQuestion: userQuestion,
+ stepIntent: step.intent,
+ toolName: step.tool,
+ toolParameters: tool.parameters,
+ completedStepSummary: completedSummary,
+ currentImageCount: images.count
+ )
+
+ guard let rawArguments = await streamLLM(prompt: argumentsPrompt, images: images) else {
+ finishPlanning(with: "⚠️ 无法提取步骤参数,请重试。")
+ return true
+ }
+
+ let cleanedArguments = cleanOutput(rawArguments)
+ guard let payload = parseJSONObject(cleanedArguments) else {
+ finishPlanning(with: "⚠️ 无法提取步骤参数,请把需求说得更具体一些。")
+ return true
+ }
+
+ if let clarification = payload["_needs_clarification"] as? String,
+ !clarification.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ finishPlanning(with: clarification)
+ return true
+ }
+
+ guard toolRegistry.validatesArguments(payload, for: step.tool) else {
+ finishPlanning(with: "⚠️ 当前步骤缺少必要参数,请把需求说得更具体一些。")
+ return true
+ }
+
+ arguments = payload
+ }
+
+ messages[cardIndex].update(
+ role: .system,
+ content: "executing:\(step.tool)",
+ skillName: displayName
+ )
+
+ do {
+ let toolResult = try await handleToolExecution(toolName: step.tool, args: arguments)
+ let toolResultSummary = toolResultSummaryForModel(
+ toolName: step.tool,
+ toolResult: toolResult
+ )
+
+ messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
+ messages.append(ChatMessage(role: .skillResult, content: toolResultSummary, skillName: step.tool))
+
+ if let payload = parsedToolPayload(from: toolResult),
+ let success = payload["success"] as? Bool,
+ !success {
+ let error = payload["error"] as? String ?? toolResultSummary
+ finishPlanning(with: error)
+ return true
+ }
+
+ completedSteps.append(
+ ExecutedPlanStep(
+ step: step,
+ toolResult: toolResult,
+ toolResultSummary: toolResultSummary
+ )
+ )
+ toolResultsForAnswer.append((toolName: step.tool, result: toolResultSummary))
+ } catch {
+ messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
+ finishPlanning(with: "❌ Tool 执行失败:\(error.localizedDescription)")
+ return true
+ }
+ }
+
+ remainingSkillIds.removeAll { executedSkillIdsThisPass.contains($0) }
+ }
+
+ if !remainingSkillIds.isEmpty {
+ finishPlanning(with: "⚠️ 还缺少部分步骤未完成,请把需求说得更具体一些。")
+ return true
+ }
+
+ let followUpPrompt = PromptBuilder.buildMultiToolAnswerPrompt(
+ originalPrompt: prompt,
+ toolResults: toolResultsForAnswer,
+ userQuestion: userQuestion,
+ currentImageCount: images.count
+ )
+
+ messages.append(ChatMessage(role: .assistant, content: "▍"))
+ let followUpIndex = messages.count - 1
+
+ guard let nextText = await streamLLM(prompt: followUpPrompt, msgIndex: followUpIndex, images: images) else {
+ markSkillsDone(Array(loadedDisplayNames.values))
+ isProcessing = false
+ return true
+ }
+
+ if !parseAllToolCalls(nextText).isEmpty {
+ log("[Agent] planner follow-up detected extra tool call")
+ messages[followUpIndex].update(content: "")
+ await executeToolChain(
+ prompt: followUpPrompt,
+ fullText: nextText,
+ userQuestion: userQuestion,
+ images: images
+ )
+ return true
+ }
+
+ let cleaned = cleanOutput(nextText)
+ let finalReply: String
+ if cleaned.isEmpty
+ || looksLikeStructuredIntermediateOutput(cleaned)
+ || looksLikePromptEcho(cleaned) {
+ finalReply = toolResultsForAnswer.map(\.result).joined(separator: "\n")
+ } else {
+ finalReply = cleaned
+ }
+
+ messages[followUpIndex].update(content: finalReply)
+ markSkillsDone(Array(loadedDisplayNames.values))
+ isProcessing = false
+ return true
+ }
+}
diff --git a/Agent/Engine/Router.swift b/Agent/Engine/Router.swift
new file mode 100644
index 0000000..08d656f
--- /dev/null
+++ b/Agent/Engine/Router.swift
@@ -0,0 +1,151 @@
+import Foundation
+
+extension AgentEngine {
+
+ // MARK: - 通用工具
+
+ func uniqueStringsPreservingOrder(_ values: [String]) -> [String] {
+ Array(NSOrderedSet(array: values)) as? [String] ?? values
+ }
+
+ // MARK: - Skill 触发匹配
+
+ /// 仅依赖 SKILL.md 的 triggers / allowedTools 字段, 零硬编关键词。
+ ///
+ /// 支持 **sticky routing**: 如果当前消息不含任何 trigger, 但最近 history
+ /// 里有活跃的 skill 上下文 (skillResult 或系统卡片里带 skillName),
+ /// 认为用户在对同一个 skill 的多轮对话中做 follow-up, 继续路由到那个 skill。
+ /// 这样 "明天下午14点的" 这种纯补全消息也能命中上一轮的 calendar skill,
+ /// 避免落到 light 路径丢失 skill 能力。
+ func matchedSkillIds(for userQuestion: String) -> [String] {
+ let normalizedQuestion = userQuestion.lowercased()
+ guard !normalizedQuestion.isEmpty else { return [] }
+
+ var matched: [String] = []
+ for entry in skillEntries where entry.isEnabled {
+ let skillId = entry.id
+ let lowercasedNames = [
+ skillId.lowercased(),
+ entry.name.lowercased()
+ ]
+
+ var isMatch = lowercasedNames.contains { normalizedQuestion.contains($0) }
+ if !isMatch,
+ let definition = skillRegistry.getDefinition(skillId) {
+ isMatch = definition.metadata.triggers.contains { trigger in
+ let normalizedTrigger = trigger.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
+ return !normalizedTrigger.isEmpty && normalizedQuestion.contains(normalizedTrigger)
+ } || definition.metadata.allowedTools.contains { toolName in
+ normalizedQuestion.contains(toolName.lowercased())
+ }
+ }
+
+ if isMatch {
+ matched.append(skillId)
+ }
+ }
+
+ // Sticky routing: 当前消息没命中任何 trigger, 但最近 history 里有
+ // 活跃 skill 上下文 -> 继续使用该 skill。
+ if matched.isEmpty, let stickySkillId = recentActiveSkillId() {
+ matched.append(stickySkillId)
+ }
+
+ return uniqueStringsPreservingOrder(matched)
+ }
+
+ /// 在"上一轮 user turn"范围内查找活跃的 skill 上下文。
+ ///
+ /// 语义边界: 从最后一条 user message 倒着扫到上一条 user message 之间,
+ /// 这一段消息是"上一轮 user turn 触发的所有 agent 行为"。在这个范围内
+ /// 找任何 .skillResult 或 .system(skillName) 消息, 第一个匹配即返回。
+ ///
+ /// 跨越上一条 user message 后停止 — 再往前的 skill 上下文已经是更早
+ /// 的对话, 不再相关。
+ ///
+ /// 为什么不用固定窗口 (suffix(4))?
+ /// 一个完整 agent loop 会 append 6-10 条消息 (load_skill, identified,
+ /// loaded, skillResult, executing, done, follow-up assistant 等),
+ /// 固定窗口 4 经常错过 skill 上下文, 导致多轮对话失去 sticky 能力。
+ /// 语义边界与 message 数量解耦, 任何长度的 agent loop 都能正确接住。
+ ///
+ /// 这是纯框架层判定 — 不感知任何具体 skill 名, 不硬编任何业务字符串。
+ private func recentActiveSkillId() -> String? {
+ var sawCurrentUser = false
+ for msg in messages.reversed() {
+ if msg.role == .user {
+ if sawCurrentUser {
+ // 跨越了上一条 user message, 停止扫描
+ return nil
+ }
+ sawCurrentUser = true
+ continue
+ }
+ guard (msg.role == .skillResult || msg.role == .system),
+ let name = msg.skillName, !name.isEmpty else {
+ continue
+ }
+
+ // name 可能是 skill id (如 "calendar") 或 tool name (如 "calendar-create-event")。
+ // 优先作为 skill id 解析; 不行再作为 tool name 反查 skill id。
+ let asSkillId = skillRegistry.canonicalSkillId(for: name)
+ if let def = skillRegistry.getDefinition(asSkillId), def.isEnabled {
+ return asSkillId
+ }
+ if let skillId = skillRegistry.findSkillId(forTool: name),
+ let def = skillRegistry.getDefinition(skillId),
+ def.isEnabled {
+ return skillId
+ }
+ }
+ return nil
+ }
+
+ func canonicalSkillSelectionEntry(_ rawValue: String) -> String? {
+ let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+
+ let directSkillId = skillRegistry.canonicalSkillId(for: trimmed)
+ if skillEntries.contains(where: { $0.isEnabled && $0.id == directSkillId }) {
+ return directSkillId
+ }
+
+ let normalizedToolName = canonicalToolName(
+ trimmed
+ .replacingOccurrences(of: "_", with: "-")
+ .lowercased(),
+ arguments: [:]
+ )
+
+ for entry in skillEntries where entry.isEnabled {
+ let toolNames = Set(registeredTools(for: entry.id).map(\.name))
+ if toolNames.contains(normalizedToolName) {
+ return entry.id
+ }
+ }
+
+ return nil
+ }
+
+ // MARK: - 路由决策
+
+ func shouldUseToolingPrompt(for userQuestion: String) -> Bool {
+ let normalizedQuestion = userQuestion.lowercased()
+ guard !normalizedQuestion.isEmpty else { return false }
+ // 完全依赖 SKILL.md 的 triggers 字段,不再硬编任何领域关键词
+ return !matchedSkillIds(for: userQuestion).isEmpty
+ }
+
+ /// 纯函数:根据已计算的条件变量确定 processInput 的路由路径。
+ /// 可独立单元测试,也用于埋点日志。
+ static func decideRoute(
+ requiresMultimodal: Bool,
+ shouldUsePlanner: Bool,
+ shouldUseFullAgentPrompt: Bool
+ ) -> String {
+ if requiresMultimodal { return "vlm" }
+ if shouldUsePlanner { return "planner" }
+ if shouldUseFullAgentPrompt { return "agent" }
+ return "light"
+ }
+}
diff --git a/Agent/Engine/SessionStore.swift b/Agent/Engine/SessionStore.swift
new file mode 100644
index 0000000..f21592b
--- /dev/null
+++ b/Agent/Engine/SessionStore.swift
@@ -0,0 +1,214 @@
+import Foundation
+
+// MARK: - SessionStore 内部数据类型
+
+struct ChatSessionRecord: Codable {
+ let id: UUID
+ var title: String
+ var preview: String
+ var updatedAt: Date
+ var messages: [ChatMessage]
+}
+
+extension AgentEngine {
+
+ // MARK: - 保存调度
+
+ func scheduleSessionSave() {
+ sessionSaveTask?.cancel()
+ let currentID = currentSessionID
+ sessionSaveTask = Task { [weak self] in
+ try? await Task.sleep(for: .milliseconds(350))
+ guard !Task.isCancelled, let self, self.currentSessionID == currentID else { return }
+ self.saveCurrentSession()
+ }
+ }
+
+ func saveCurrentSession() {
+ guard !messages.isEmpty else { return }
+
+ let summary = makeSessionSummary(
+ id: currentSessionID,
+ messages: messages
+ )
+ let record = ChatSessionRecord(
+ id: currentSessionID,
+ title: summary.title,
+ preview: summary.preview,
+ updatedAt: summary.updatedAt,
+ messages: messages
+ )
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+
+ do {
+ let directory = try ensureSessionsDirectory()
+ let data = try encoder.encode(record)
+ try data.write(to: sessionFileURL(for: currentSessionID), options: .atomic)
+ updateSessionSummary(summary)
+ persistSessionsIndex()
+ UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
+ _ = directory
+ } catch {
+ log("[History] save failed: \(error.localizedDescription)")
+ }
+ }
+
+ // MARK: - 加载
+
+ func loadPersistedSessions() {
+ do {
+ _ = try ensureSessionsDirectory()
+ } catch {
+ log("[History] setup failed: \(error.localizedDescription)")
+ }
+
+ sessionSummaries = loadSessionsIndex().sorted { $0.updatedAt > $1.updatedAt }
+
+ if let record = loadSessionRecord(id: currentSessionID) {
+ messages = record.messages
+ updateSessionSummary(
+ .init(
+ id: record.id,
+ title: record.title,
+ preview: record.preview,
+ updatedAt: record.updatedAt
+ )
+ )
+ return
+ }
+
+ if let first = sessionSummaries.first, let record = loadSessionRecord(id: first.id) {
+ currentSessionID = first.id
+ UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
+ messages = record.messages
+ return
+ }
+
+ currentSessionID = UUID()
+ UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
+ messages = []
+ }
+
+ func loadSessionRecord(id: UUID) -> ChatSessionRecord? {
+ let url = sessionFileURL(for: id)
+ guard let data = try? Data(contentsOf: url) else { return nil }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return try? decoder.decode(ChatSessionRecord.self, from: data)
+ }
+
+ // MARK: - 摘要
+
+ func makeSessionSummary(id: UUID, messages: [ChatMessage]) -> ChatSessionSummary {
+ let firstUser = messages.first(where: { $0.role == .user })
+ let lastMeaningful = messages.reversed().first(where: { msg in
+ if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return true
+ }
+ return !msg.images.isEmpty || !msg.audios.isEmpty
+ })
+
+ let title = sessionTitle(from: firstUser)
+ let preview = sessionPreview(from: lastMeaningful ?? firstUser)
+ let updatedAt = (lastMeaningful ?? firstUser)?.timestamp ?? Date()
+
+ return ChatSessionSummary(
+ id: id,
+ title: title,
+ preview: preview,
+ updatedAt: updatedAt
+ )
+ }
+
+ private func sessionTitle(from message: ChatMessage?) -> String {
+ guard let message else { return "新会话" }
+ let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ return String(trimmed.prefix(24))
+ }
+ if !message.images.isEmpty && !message.audios.isEmpty {
+ return "图片与语音会话"
+ }
+ if !message.images.isEmpty {
+ return "图片会话"
+ }
+ if !message.audios.isEmpty {
+ return "语音会话"
+ }
+ return "新会话"
+ }
+
+ private func sessionPreview(from message: ChatMessage?) -> String {
+ guard let message else { return "暂无内容" }
+ let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ return String(trimmed.prefix(80))
+ }
+ if !message.images.isEmpty && !message.audios.isEmpty {
+ return "包含图片与语音"
+ }
+ if !message.images.isEmpty {
+ return "包含图片"
+ }
+ if !message.audios.isEmpty {
+ return "包含语音"
+ }
+ return "暂无内容"
+ }
+
+ func updateSessionSummary(_ summary: ChatSessionSummary) {
+ if let index = sessionSummaries.firstIndex(where: { $0.id == summary.id }) {
+ sessionSummaries[index] = summary
+ } else {
+ sessionSummaries.append(summary)
+ }
+ sessionSummaries.sort { $0.updatedAt > $1.updatedAt }
+ }
+
+ // MARK: - 索引文件
+
+ func persistSessionsIndex() {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ do {
+ let data = try encoder.encode(sessionSummaries)
+ try data.write(to: sessionsIndexURL(), options: .atomic)
+ } catch {
+ log("[History] index save failed: \(error.localizedDescription)")
+ }
+ }
+
+ private func loadSessionsIndex() -> [ChatSessionSummary] {
+ let url = sessionsIndexURL()
+ guard let data = try? Data(contentsOf: url) else { return [] }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return (try? decoder.decode([ChatSessionSummary].self, from: data)) ?? []
+ }
+
+ // MARK: - URL helpers
+
+ func ensureSessionsDirectory() throws -> URL {
+ let directory = sessionsDirectoryURL()
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ return directory
+ }
+
+ func sessionsIndexURL() -> URL {
+ sessionsDirectoryURL().appendingPathComponent(sessionsIndexFileName)
+ }
+
+ func sessionFileURL(for id: UUID) -> URL {
+ sessionsDirectoryURL().appendingPathComponent("\(id.uuidString).json")
+ }
+
+ func sessionsDirectoryURL() -> URL {
+ let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ let appDir = supportDir.appendingPathComponent("PhoneClaw", isDirectory: true)
+ return appDir.appendingPathComponent(sessionsDirectoryName, isDirectory: true)
+ }
+}
diff --git a/Agent/Engine/ToolCallParser.swift b/Agent/Engine/ToolCallParser.swift
new file mode 100644
index 0000000..cd3246e
--- /dev/null
+++ b/Agent/Engine/ToolCallParser.swift
@@ -0,0 +1,170 @@
+import Foundation
+
+extension AgentEngine {
+
+ // MARK: - 工具名归一化
+
+ func canonicalToolName(
+ _ toolName: String,
+ arguments: [String: Any],
+ preferredSkillId: String? = nil
+ ) -> String {
+ let normalizedToolName = toolName
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: "_", with: "-")
+ .lowercased()
+
+ // 协议级 sentinel tool 的名称归一化。
+ // load_skill / list_skills 不是注册在 ToolRegistry 的业务工具,
+ // 而是 AgentEngine 与 LLM 之间的协议入口(在 executeToolChain 内被特殊
+ // hijack 处理),因此其名称归一化属于 caller 协议范畴而非 per-skill 硬编。
+ switch normalizedToolName {
+ case "load-skill":
+ return "load_skill"
+ case "list-skills", "list-skill", "listskills":
+ return "list_skills"
+ default:
+ break
+ }
+
+ // 委托 ToolRegistry 别名查询
+ if let canonical = toolRegistry.canonicalName(for: normalizedToolName) {
+ return canonical
+ }
+
+ // 按 preferredSkillId 做 suffix 匹配(通用逻辑,不涉及领域语义)
+ if let preferredSkillId {
+ let resolvedSkillId = skillRegistry.canonicalSkillId(for: preferredSkillId)
+ let allowedTools = registeredTools(for: resolvedSkillId).map(\.name)
+
+ if allowedTools.contains(normalizedToolName) {
+ return normalizedToolName
+ }
+
+ let suffixMatches = allowedTools.filter { allowedTool in
+ let normalizedAllowed = allowedTool.lowercased()
+ if normalizedAllowed.hasSuffix("-" + normalizedToolName) {
+ return true
+ }
+ let strippedPrefix = normalizedAllowed.replacingOccurrences(
+ of: resolvedSkillId.lowercased() + "-",
+ with: ""
+ )
+ return strippedPrefix == normalizedToolName
+ }
+ if suffixMatches.count == 1, let match = suffixMatches.first {
+ return match
+ }
+
+ if allowedTools.count == 1, let onlyTool = allowedTools.first {
+ let strippedPrefix = onlyTool
+ .lowercased()
+ .replacingOccurrences(of: resolvedSkillId.lowercased() + "-", with: "")
+ if strippedPrefix.contains(normalizedToolName)
+ || normalizedToolName.contains(strippedPrefix) {
+ return onlyTool
+ }
+ }
+ }
+
+ return normalizedToolName
+ }
+
+ // MARK: - JSON 解析
+
+ func parseJSONObject(_ text: String) -> [String: Any]? {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+
+ let candidates: [String] = {
+ if trimmed.hasPrefix("```json") || trimmed.hasPrefix("```") {
+ let stripped = trimmed
+ .replacingOccurrences(of: "```json", with: "")
+ .replacingOccurrences(of: "```", with: "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return [stripped]
+ }
+
+ if let start = trimmed.firstIndex(of: "{"),
+ let end = trimmed.lastIndex(of: "}"),
+ start <= end {
+ return [trimmed, String(trimmed[start...end])]
+ }
+
+ return [trimmed]
+ }()
+
+ for candidate in candidates {
+ guard let data = candidate.data(using: .utf8),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ continue
+ }
+ return object
+ }
+
+ return nil
+ }
+
+ // MARK: - tool_call 文本解析
+ //
+ // Cage: parser 是框架边界, 同时承担"白名单校验"职责。任何不在
+ // ToolRegistry (业务工具) 或 protocolTools (load_skill / list_skills 等
+ // 框架内置 sentinel) 的名字一律 drop, 防止 LLM 幻觉穿透到 executeToolChain
+ // 创建 phantom card / 触发未知 tool 错误。
+ //
+ // 这是 ToolRegistry 真正成为"白名单"的地方——之前 registry 只在
+ // executeToolChain 末梢被消费, 校验缺位导致幻觉的 tool_call 在框架内
+ // 多次易手才被丢弃。
+
+ /// 协议级 sentinel tool: 不在 ToolRegistry 注册, 但是框架与 LLM 的
+ /// 通信协议入口, 由 executeToolChain 内特殊 hijack 处理。
+ private static let protocolTools: Set = ["load_skill", "list_skills"]
+
+ func parseToolCall(_ text: String) -> (name: String, arguments: [String: Any])? {
+ return parseAllToolCalls(text).first
+ }
+
+ func parseAllToolCalls(_ text: String) -> [(name: String, arguments: [String: Any])] {
+ let raw = rawParseToolCalls(text)
+ return raw.compactMap { call in
+ let canonical = canonicalToolName(call.name, arguments: call.arguments)
+ if Self.protocolTools.contains(canonical) {
+ return (canonical, call.arguments)
+ }
+ if toolRegistry.find(name: canonical) != nil {
+ return (canonical, call.arguments)
+ }
+ print("[Parser] dropped invalid tool_call: \(call.name) (canonical: \(canonical))")
+ return nil
+ }
+ }
+
+ /// 原始正则提取, 不做任何 registry 校验。
+ /// 仅供 parseAllToolCalls 内部使用; 外部 caller 必须走 parseAllToolCalls
+ /// 以确保 cage 校验不被绕过。
+ private func rawParseToolCalls(_ text: String) -> [(name: String, arguments: [String: Any])] {
+ var results: [(name: String, arguments: [String: Any])] = []
+ let patterns = [
+ "\\s*(\\{.*?\\})\\s*",
+ "```json\\s*(\\{.*?\\})\\s*```",
+ "\\s*(\\{.*?\\})\\s*"
+ ]
+ for pattern in patterns {
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: .dotMatchesLineSeparators) else { continue }
+ let range = NSRange(text.startIndex..., in: text)
+ let matches = regex.matches(in: text, range: range)
+ for match in matches {
+ if let jsonRange = Range(match.range(at: 1), in: text) {
+ let json = String(text[jsonRange])
+ if let data = json.data(using: .utf8),
+ let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let name = dict["name"] as? String {
+ results.append((name, dict["arguments"] as? [String: Any] ?? [:]))
+ }
+ }
+ }
+ if !results.isEmpty { break }
+ }
+ return results
+ }
+}
diff --git a/Agent/Engine/ToolChain.swift b/Agent/Engine/ToolChain.swift
new file mode 100644
index 0000000..b4fa5c9
--- /dev/null
+++ b/Agent/Engine/ToolChain.swift
@@ -0,0 +1,579 @@
+import CoreImage
+import Foundation
+
+// MARK: - ToolChain 内部数据类型
+
+enum SingleToolExtractionOutcome {
+ case toolCall(name: String, arguments: [String: Any])
+ case needsClarification(String)
+ case failed
+}
+
+extension AgentEngine {
+
+ // MARK: - Tool 注册查询
+
+ func registeredTools(for skillId: String) -> [RegisteredTool] {
+ if let def = skillRegistry.getDefinition(skillId) {
+ let tools = toolRegistry.toolsFor(names: def.metadata.allowedTools)
+ if !tools.isEmpty { return tools }
+ }
+
+ if let entry = skillEntries.first(where: { $0.id == skillId }) {
+ let tools = entry.tools.compactMap { toolRegistry.find(name: $0.name) }
+ if !tools.isEmpty { return tools }
+ }
+
+ return []
+ }
+
+ // MARK: - 单 Skill 自动 / 引导式工具调用
+
+ func autoToolCallForLoadedSkills(
+ skillIds: [String]
+ ) -> (name: String, arguments: [String: Any])? {
+ let uniqueSkillIds = Array(NSOrderedSet(array: skillIds)) as? [String] ?? skillIds
+
+ guard uniqueSkillIds.count == 1,
+ let skillId = uniqueSkillIds.first,
+ let def = skillRegistry.getDefinition(skillId),
+ def.isEnabled else {
+ return nil
+ }
+
+ let uniqueToolNames = Array(NSOrderedSet(array: def.metadata.allowedTools)) as? [String]
+ ?? def.metadata.allowedTools
+ guard uniqueToolNames.count == 1,
+ let toolName = uniqueToolNames.first,
+ let tool = toolRegistry.find(name: toolName),
+ tool.isParameterless else {
+ return nil
+ }
+
+ return (tool.name, [:])
+ }
+
+ func singleRegisteredToolForLoadedSkills(skillIds: [String]) -> RegisteredTool? {
+ let uniqueSkillIds = Array(NSOrderedSet(array: skillIds)) as? [String] ?? skillIds
+ guard uniqueSkillIds.count == 1,
+ let skillId = uniqueSkillIds.first else {
+ return nil
+ }
+
+ let tools = registeredTools(for: skillId)
+ guard tools.count == 1 else { return nil }
+ return tools.first
+ }
+
+ func extractToolCallForLoadedSkills(
+ originalPrompt: String,
+ userQuestion: String,
+ skillInstructions: String,
+ skillIds: [String],
+ images: [CIImage]
+ ) async -> SingleToolExtractionOutcome {
+ let uniqueSkillIds = Array(NSOrderedSet(array: skillIds)) as? [String] ?? skillIds
+ guard uniqueSkillIds.count == 1,
+ let skillId = uniqueSkillIds.first else {
+ return .failed
+ }
+
+ let tools = registeredTools(for: skillId)
+ .filter { !$0.isParameterless }
+ 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 toolRegistry.validatesArguments(payload, for: tool.name) {
+ return .toolCall(name: tool.name, arguments: payload)
+ }
+ }
+ }
+
+ 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,
+ allowedToolsSummary: allowedToolsSummary,
+ 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 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 }),
+ toolRegistry.validatesArguments(arguments, for: toolName) {
+ return .toolCall(name: toolName, arguments: arguments)
+ }
+ }
+ }
+ }
+
+ return .failed
+ }
+
+ // MARK: - synthetic / payload helpers
+
+ func syntheticToolCallText(
+ name: String,
+ arguments: [String: Any]
+ ) -> String {
+ let jsonData = try? JSONSerialization.data(withJSONObject: [
+ "name": name,
+ "arguments": arguments
+ ])
+ let jsonString = jsonData.flatMap { String(data: $0, encoding: .utf8) }
+ ?? "{\"name\":\"\(name)\",\"arguments\":{}}"
+ return """
+
+ \(jsonString)
+
+ """
+ }
+
+ func parsedToolPayload(from toolResult: String) -> [String: Any]? {
+ guard let data = toolResult.data(using: .utf8),
+ let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ return nil
+ }
+ return payload
+ }
+
+ func toolResultSummaryForModel(
+ toolName: String,
+ toolResult: String
+ ) -> String {
+ let trimmed = toolResult.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return "工具 \(toolName) 已执行,但没有返回内容。" }
+
+ if let payload = parsedToolPayload(from: trimmed) {
+ if let success = payload["success"] as? Bool,
+ !success,
+ let error = payload["error"] as? String,
+ !error.isEmpty {
+ return "工具 \(toolName) 执行失败:\(error)"
+ }
+
+ if let result = payload["result"] as? String {
+ let summary = result.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !summary.isEmpty { return summary }
+ }
+ }
+
+ return trimmed
+ }
+
+ func fallbackReplyForEmptyToolFollowUp(toolName: String, toolResult: String) -> String {
+ let trimmed = toolResult.trimmingCharacters(in: .whitespacesAndNewlines)
+ let summary = toolResultSummaryForModel(toolName: toolName, toolResult: trimmed)
+ if !summary.isEmpty, summary != trimmed {
+ return summary
+ }
+
+ if trimmed.isEmpty {
+ return "工具 \(toolName) 已执行,但没有返回内容。"
+ }
+
+ return """
+ 工具 \(toolName) 已执行完成,但模型没有生成最终回答。
+ 工具返回结果:
+ \(trimmed)
+ """
+ }
+
+ func fallbackReplyForEmptySkillFollowUp(skillName: String) -> String {
+ "Skill \(skillName) 已加载,但模型没有继续生成工具调用或最终回答。请重试,或把问题说得更具体一些。"
+ }
+
+ func markSkillsDone(_ displayNames: [String]) {
+ guard !displayNames.isEmpty else { return }
+ for index in messages.indices {
+ guard messages[index].role == .system,
+ let skillName = messages[index].skillName,
+ displayNames.contains(skillName),
+ messages[index].content == "identified" || messages[index].content == "loaded" else {
+ continue
+ }
+ messages[index].update(role: .system, content: "done", skillName: skillName)
+ }
+ }
+
+ // MARK: - Tool 调用主循环
+
+ func executeToolChain(
+ prompt: String,
+ fullText: String,
+ userQuestion: String,
+ images: [CIImage],
+ round: Int = 1,
+ maxRounds: Int = 10
+ ) async {
+ guard round <= maxRounds else {
+ log("[Agent] 达到最大工具链轮数 \(maxRounds)")
+ isProcessing = false
+ return
+ }
+
+ 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)
+ }
+ isProcessing = false
+ return
+ }
+
+ let call = (
+ name: canonicalToolName(parsedCall.name, arguments: parsedCall.arguments),
+ arguments: parsedCall.arguments
+ )
+
+ log("[Agent] Round \(round): tool_call name=\(call.name)")
+
+ // ── list_skills ──
+ if call.name == "list_skills" {
+ let query = (call.arguments["query"] as? String ?? "").lowercased()
+ let results = skillEntries.filter(\.isEnabled).filter { entry in
+ guard !query.isEmpty else { return true }
+ return entry.id.lowercased().contains(query)
+ || entry.name.lowercased().contains(query)
+ || entry.description.lowercased().contains(query)
+ }
+ let listing = results.map { "\($0.id): \($0.description)" }.joined(separator: "\n")
+ let resultText = results.isEmpty
+ ? "没有找到匹配「\(query)」的能力。"
+ : "可用能力(\(results.count) 个):\n\(listing)"
+ log("[Agent] list_skills query=\"\(query)\" results=\(results.count)")
+
+ let toolResultSummary = toolResultSummaryForModel(toolName: "list_skills", toolResult: resultText)
+ messages.append(ChatMessage(role: .skillResult, content: toolResultSummary, skillName: "list_skills"))
+
+ let followUpPrompt = PromptBuilder.buildToolAnswerPrompt(
+ originalPrompt: prompt,
+ toolName: "list_skills",
+ toolResultSummary: toolResultSummary,
+ userQuestion: userQuestion,
+ currentImageCount: images.count
+ )
+
+ messages.append(ChatMessage(role: .assistant, content: "▍"))
+ let followUpIndex = messages.count - 1
+
+ guard let nextText = await streamLLM(prompt: followUpPrompt, msgIndex: followUpIndex, images: images) else {
+ isProcessing = false
+ return
+ }
+
+ if parseToolCall(nextText) != nil {
+ log("[Agent] list_skills 后检测到 tool 调用 (round \(round + 1))")
+ messages[followUpIndex].update(content: "")
+ await executeToolChain(
+ prompt: followUpPrompt, fullText: nextText,
+ userQuestion: userQuestion, images: images, round: round + 1, maxRounds: maxRounds
+ )
+ } else {
+ let cleaned = cleanOutput(nextText)
+ messages[followUpIndex].update(content: cleaned.isEmpty ? "(无回复)" : cleaned)
+ isProcessing = false
+ }
+ return
+ }
+
+ // ── load_skill ──
+ if call.name == "load_skill" {
+ let allCalls = parseAllToolCalls(fullText)
+ let loadSkillCalls = allCalls.filter { $0.name == "load_skill" }
+
+ var allInstructions = ""
+ var loadedDisplayNames: [String] = []
+ var loadedSkillIds: [String] = []
+ for lsCall in loadSkillCalls {
+ let requestedSkillName = (lsCall.arguments["skill"] as? String)
+ ?? (lsCall.arguments["name"] as? String)
+ ?? ""
+ let skillName = skillRegistry.canonicalSkillId(for: requestedSkillName)
+ log("[Agent] load_skill: \(requestedSkillName)")
+
+ let displayName = findDisplayName(for: skillName)
+ loadedDisplayNames.append(displayName)
+ messages.append(ChatMessage(role: .system, content: "identified", skillName: displayName))
+ let cardIdx = messages.count - 1
+
+ guard let instructions = handleLoadSkill(skillName: skillName) else {
+ messages[cardIdx].update(role: .system, content: "done", skillName: displayName)
+ continue
+ }
+
+ try? await Task.sleep(for: .milliseconds(300))
+ messages[cardIdx].update(role: .system, content: "loaded", skillName: displayName)
+ messages.append(ChatMessage(role: .skillResult, content: instructions, skillName: skillName))
+ allInstructions += instructions + "\n\n"
+ loadedSkillIds.append(skillName)
+ }
+
+ guard !allInstructions.isEmpty else {
+ isProcessing = false
+ return
+ }
+
+ if let autoCall = autoToolCallForLoadedSkills(skillIds: loadedSkillIds) {
+ let syntheticToolCall = syntheticToolCallText(
+ name: autoCall.name,
+ arguments: autoCall.arguments
+ )
+ await executeToolChain(
+ prompt: prompt,
+ fullText: syntheticToolCall,
+ userQuestion: userQuestion,
+ images: images,
+ round: round + 1,
+ maxRounds: maxRounds
+ )
+ return
+ }
+
+ let singleToolExtraction = await extractToolCallForLoadedSkills(
+ originalPrompt: prompt,
+ userQuestion: userQuestion,
+ skillInstructions: allInstructions,
+ skillIds: loadedSkillIds,
+ images: images
+ )
+ switch singleToolExtraction {
+ case .toolCall(let name, let arguments):
+ log("[Agent] load_skill 参数提取后执行工具: \(name)")
+ let syntheticToolCall = syntheticToolCallText(name: name, arguments: arguments)
+ await executeToolChain(
+ prompt: prompt,
+ fullText: syntheticToolCall,
+ userQuestion: userQuestion,
+ images: images,
+ round: round + 1,
+ maxRounds: maxRounds
+ )
+ return
+
+ case .needsClarification(let clarification):
+ messages.append(ChatMessage(role: .assistant, content: clarification))
+ markSkillsDone(loadedDisplayNames)
+ isProcessing = false
+ return
+
+ case .failed:
+ break
+ }
+
+ // 计算所有 loaded skill 的 allowed-tools 并集 (去重)
+ // — 这是 Scaffold T2 disclosure 的输入: 告诉模型哪些工具实际可调
+ let availableTools: [String] = {
+ var seen = Set()
+ var ordered: [String] = []
+ for skillId in loadedSkillIds {
+ guard let def = skillRegistry.getDefinition(skillId) else { continue }
+ for toolName in def.metadata.allowedTools where !seen.contains(toolName) {
+ seen.insert(toolName)
+ ordered.append(toolName)
+ }
+ }
+ return ordered
+ }()
+
+ let followUpPrompt = PromptBuilder.buildLoadedSkillPrompt(
+ originalPrompt: prompt,
+ userQuestion: userQuestion,
+ skillInstructions: allInstructions,
+ availableTools: availableTools,
+ currentImageCount: images.count
+ )
+
+ messages.append(ChatMessage(role: .assistant, content: "▍"))
+ let followUpIndex = messages.count - 1
+
+ guard let nextText = await streamLLM(prompt: followUpPrompt, msgIndex: followUpIndex, images: images) else {
+ isProcessing = false
+ return
+ }
+
+ if parseToolCall(nextText) != nil {
+ log("[Agent] load_skill 后检测到 tool 调用 (round \(round + 1))")
+ messages[followUpIndex].update(content: "")
+ await executeToolChain(
+ prompt: followUpPrompt, fullText: nextText,
+ userQuestion: userQuestion, images: images, round: round + 1, maxRounds: maxRounds
+ )
+ } else {
+ let cleaned = cleanOutput(nextText)
+ if cleaned.isEmpty
+ || looksLikeStructuredIntermediateOutput(cleaned)
+ || looksLikePromptEcho(cleaned) {
+ let retryPrompt = PromptBuilder.buildLoadedSkillPrompt(
+ originalPrompt: prompt,
+ userQuestion: userQuestion,
+ skillInstructions: allInstructions,
+ availableTools: availableTools,
+ currentImageCount: images.count,
+ forceResponse: true
+ )
+
+ guard let retryText = await streamLLM(prompt: retryPrompt, msgIndex: followUpIndex, images: images) else {
+ isProcessing = false
+ return
+ }
+
+ if parseToolCall(retryText) != nil {
+ log("[Agent] load_skill 重试后检测到 tool 调用 (round \(round + 1))")
+ messages[followUpIndex].update(content: "")
+ await executeToolChain(
+ prompt: retryPrompt,
+ fullText: retryText,
+ userQuestion: userQuestion,
+ images: images,
+ round: round + 1,
+ maxRounds: maxRounds
+ )
+ } else {
+ let retryCleaned = cleanOutput(retryText)
+ let loadedSkillName = loadedDisplayNames.joined(separator: ", ").isEmpty
+ ? "已加载的能力"
+ : loadedDisplayNames.joined(separator: ", ")
+ let finalReply = retryCleaned.isEmpty
+ || looksLikeStructuredIntermediateOutput(retryCleaned)
+ || looksLikePromptEcho(retryCleaned)
+ ? fallbackReplyForEmptySkillFollowUp(skillName: loadedSkillName)
+ : retryCleaned
+ messages[followUpIndex].update(content: finalReply)
+ markSkillsDone(loadedDisplayNames)
+ isProcessing = false
+ }
+ } else {
+ messages[followUpIndex].update(content: cleaned)
+ markSkillsDone(loadedDisplayNames)
+ isProcessing = false
+ }
+ }
+ return
+ }
+
+ // ── 具体 Tool 调用 ──
+
+ let ownerSkillId = findSkillId(for: call.name)
+ let displayName = findDisplayName(for: call.name)
+
+ let cardIndex: Int
+ if let idx = messages.lastIndex(where: {
+ $0.role == .system && ($0.skillName == displayName || $0.skillName == call.name)
+ && ($0.content == "identified" || $0.content == "loaded")
+ }) {
+ cardIndex = idx
+ } else {
+ messages.append(ChatMessage(role: .system, content: "identified", skillName: displayName))
+ cardIndex = messages.count - 1
+ }
+
+ guard ownerSkillId != nil else {
+ messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
+ messages.append(ChatMessage(role: .assistant, content: "⚠️ 未知工具: \(call.name)"))
+ isProcessing = false
+ return
+ }
+
+ let enabledIds = Set(skillEntries.filter(\.isEnabled).map(\.id))
+ guard enabledIds.contains(ownerSkillId!) else {
+ messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
+ messages.append(ChatMessage(role: .assistant, content: "⚠️ Skill \(displayName) 未启用"))
+ isProcessing = false
+ return
+ }
+
+ messages[cardIndex].update(role: .system, content: "executing:\(call.name)", skillName: displayName)
+
+ do {
+ let toolResult = try await handleToolExecution(toolName: call.name, args: call.arguments)
+ let toolResultSummary = toolResultSummaryForModel(toolName: call.name, toolResult: toolResult)
+ messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
+ messages.append(ChatMessage(role: .skillResult, content: toolResultSummary, skillName: call.name))
+ log("[Agent] Tool \(call.name) round \(round) done")
+
+ if toolRegistry.shouldSkipFollowUp(for: call.name) {
+ messages.append(ChatMessage(role: .assistant, content: toolResultSummary))
+ isProcessing = false
+ return
+ }
+
+ let followUpPrompt = PromptBuilder.buildToolAnswerPrompt(
+ originalPrompt: prompt,
+ toolName: call.name,
+ toolResultSummary: toolResultSummary,
+ userQuestion: userQuestion,
+ currentImageCount: images.count
+ )
+
+ messages.append(ChatMessage(role: .assistant, content: "▍"))
+ let followUpIndex = messages.count - 1
+
+ guard let nextText = await streamLLM(prompt: followUpPrompt, msgIndex: followUpIndex, images: images) else {
+ isProcessing = false
+ return
+ }
+
+ if !parseAllToolCalls(nextText).isEmpty {
+ log("[Agent] 检测到第 \(round + 1) 轮工具调用")
+ messages[followUpIndex].update(content: "")
+ await executeToolChain(
+ prompt: followUpPrompt, fullText: nextText,
+ userQuestion: userQuestion, images: images, round: round + 1, maxRounds: maxRounds
+ )
+ } else {
+ let cleaned = cleanOutput(nextText)
+ if cleaned.isEmpty
+ || looksLikeStructuredIntermediateOutput(cleaned)
+ || looksLikePromptEcho(cleaned) {
+ messages[followUpIndex].update(content: fallbackReplyForEmptyToolFollowUp(
+ toolName: call.name,
+ toolResult: toolResult
+ ))
+ } else {
+ messages[followUpIndex].update(content: cleaned)
+ }
+ isProcessing = false
+ }
+ } catch {
+ messages[cardIndex].update(role: .system, content: "done", skillName: displayName)
+ messages.append(ChatMessage(role: .system, content: "❌ Tool 执行失败: \(error)"))
+ isProcessing = false
+ }
+ }
+}
diff --git a/LLM/MLX/Core/BundledModel.swift b/LLM/MLX/Core/BundledModel.swift
new file mode 100644
index 0000000..11731f7
--- /dev/null
+++ b/LLM/MLX/Core/BundledModel.swift
@@ -0,0 +1,209 @@
+import Foundation
+
+// MARK: - Runtime Profile 类型
+//
+// 设计目标: 把"哪个模型在 headroom X 时给 Y token"的所有业务参数, 从硬编码
+// switch / tier 表迁移成**连续的线性公式**。原因:
+// 1. tier 表是 step function, 在边界附近 1 MB 差距导致几百 token 跳变, 不直观
+// 2. 标定 tier 表需要逐档调数字, 校准成本高; 公式只调 4 个参数
+// 3. chunked prefill 已让 prepared 长度对峰值内存影响极小, 可以用线性外推
+//
+// 所有 token 预算 = clamp(min, max(0, headroom - safetyMargin) * tokensPerMB, max)
+//
+// 多模态和 historyDepth 仍保留 tier 表, 因为:
+// - 多模态: imageSoftCap + maxOut 双约束, 而且有硬天花板 512, 不是单调线性
+// - historyDepth: 整数消息数, 只有 4 个离散值
+
+/// 线性预算公式:
+/// tokens = clamp(minTokens, max(0, headroom - safetyMarginMB) * tokensPerMB, maxTokens)
+public struct LinearBudgetFormula: Sendable, Equatable {
+ public let safetyMarginMB: Int // headroom 减去这个量才参与计算 (留给系统/transient peak)
+ public let tokensPerMB: Double // 1 MB usable headroom 折算多少 token
+ public let minTokens: Int // 下限 (即使 headroom 极低也保证最少这么多)
+ public let maxTokens: Int // 上限 (避免 headroom 极大时给出荒诞的输出长度)
+
+ public init(safetyMarginMB: Int, tokensPerMB: Double, minTokens: Int, maxTokens: Int) {
+ self.safetyMarginMB = safetyMarginMB
+ self.tokensPerMB = tokensPerMB
+ self.minTokens = minTokens
+ self.maxTokens = maxTokens
+ }
+
+ public func evaluate(headroom: Int) -> Int {
+ let usable = max(0, headroom - safetyMarginMB)
+ let raw = Int((Double(usable) * tokensPerMB).rounded())
+ return min(maxTokens, max(minTokens, raw))
+ }
+}
+
+/// 单档预算 (仅用于 historyDepth, 整数离散映射)
+public struct BudgetTier: Sendable, Equatable {
+ public let headroomMaxMB: Int // headroom < 此值时命中此档
+ public let tokens: Int // 此处 tokens 字段是"消息条数"
+
+ public init(headroomMaxMB: Int, tokens: Int) {
+ self.headroomMaxMB = headroomMaxMB
+ self.tokens = tokens
+ }
+}
+
+/// 多模态单档预算: 双约束 (image cap + max output) + 硬天花板
+public struct MultimodalTier: Sendable, Equatable {
+ public let headroomMaxMB: Int
+ public let imageSoftTokenCap: Int?
+ public let maxOutputTokens: Int
+
+ public init(headroomMaxMB: Int, imageSoftTokenCap: Int?, maxOutputTokens: Int) {
+ self.headroomMaxMB = headroomMaxMB
+ self.imageSoftTokenCap = imageSoftTokenCap
+ self.maxOutputTokens = maxOutputTokens
+ }
+}
+
+/// 模型运行时 profile
+///
+/// 历史背景:
+/// 早期版本有 textSequenceBudget / thinkingSequenceBudget 两个字段, 用来表达
+/// "prompt + output 的总序列预算"。这是 chunked prefill 之前的设计, 假设 KV
+/// cache 与总序列长度线性增长, prompt 必须从 output 预算里扣减。
+///
+/// chunked prefill (windowSize=256) 上线后, 单次 forward 的 transient 内存
+/// 与 prompt 长度解耦, prepared 长度对峰值内存几乎无影响 (实测 prepared
+/// 290 → 3319 之间, footprint Δ 完全不相关)。"总序列预算"概念失去意义,
+/// 已删除。output 上限只受 textOutputBudget / thinkingOutputBudget 单一约束,
+/// 它们已经是 headroom 的函数, 内存吃紧时自动收紧, 无需再做 prepared 扣减。
+public struct ModelRuntimeProfile: Sendable {
+ /// 触发 thinking 模式的 marker (nil = 不支持 thinking)
+ public let thinkingMarker: String?
+
+ /// 普通文本输出 token 上限 (单次 generateStream 的硬上限, 不含 prompt)
+ public let textOutputBudget: LinearBudgetFormula
+
+ /// thinking 输出 token 上限
+ public let thinkingOutputBudget: LinearBudgetFormula
+
+ /// 多模态输出: 保留 tier 表 (双约束 + 硬天花板, 不适合线性化)
+ public let multimodalOutputTiers: [MultimodalTier]
+
+ /// 多模态严格下限 (headroom <= 此值直接 throw multimodalMemoryRisk)
+ /// 0 = 不做严格下限检查
+ public let multimodalCriticalHeadroomMB: Int
+
+ /// safeHistoryDepth: 整数离散映射, 保留 tier 表
+ public let historyDepthTiers: [BudgetTier]
+
+ /// 多模态失败时建议切换到的更轻量模型 ID
+ public let lighterAlternativeID: String?
+
+ public init(
+ thinkingMarker: String?,
+ textOutputBudget: LinearBudgetFormula,
+ thinkingOutputBudget: LinearBudgetFormula,
+ multimodalOutputTiers: [MultimodalTier],
+ multimodalCriticalHeadroomMB: Int,
+ historyDepthTiers: [BudgetTier],
+ lighterAlternativeID: String?
+ ) {
+ self.thinkingMarker = thinkingMarker
+ self.textOutputBudget = textOutputBudget
+ self.thinkingOutputBudget = thinkingOutputBudget
+ self.multimodalOutputTiers = multimodalOutputTiers
+ self.multimodalCriticalHeadroomMB = multimodalCriticalHeadroomMB
+ self.historyDepthTiers = historyDepthTiers
+ self.lighterAlternativeID = lighterAlternativeID
+ }
+}
+
+// MARK: - Gemma 4 Profiles
+//
+// 公式系数标定原则 (基于 2026-04-08 实测日志):
+// - E4B baseline footprint = 4550 MB, 生成时峰值 +400 MB transient
+// - jetsam = 6144 MB, 实测 headroom 稳定在 1500-1600 MB
+// - safetyMarginMB = 300: 留 200 MB jetsam buffer + 100 MB transient overhead
+// - tokensPerMB 约为 1.0-1.8: 1 MB usable headroom 大概折 1-2 个 token 的 KV cache
+// - sequence > output: 序列预算包含 prompt + output, 系数比 output 更高
+
+public enum MLXModelProfiles {
+
+ // MARK: Gemma 4 E2B — 轻量, 26 layers (KV cache 较小)
+
+ public static let gemma4_e2b = ModelRuntimeProfile(
+ thinkingMarker: "<|think|>",
+
+ // 单次输出 token 上限
+ // @500: 1.4*250=350→min384; @1000: 1.4*750=1050; @1500: 1.4*1250=1750; @2000: cap 2048
+ textOutputBudget: LinearBudgetFormula(
+ safetyMarginMB: 250, tokensPerMB: 1.4, minTokens: 384, maxTokens: 2_048
+ ),
+
+ // thinking 输出比文本更紧 (避免思考占满预算把答案挤掉)
+ // @500: min; @1000: 1.1*750=825; @1500: 1.1*1250=1375; @2000: cap 1536
+ thinkingOutputBudget: LinearBudgetFormula(
+ safetyMarginMB: 250, tokensPerMB: 1.1, minTokens: 256, maxTokens: 1_536
+ ),
+
+ // 多模态: E2B 不像 E4B 那么吃内存, 平表
+ multimodalOutputTiers: [
+ MultimodalTier(headroomMaxMB: .max, imageSoftTokenCap: 160, maxOutputTokens: 512),
+ ],
+ multimodalCriticalHeadroomMB: 0,
+
+ historyDepthTiers: [
+ BudgetTier(headroomMaxMB: 500, tokens: 0),
+ BudgetTier(headroomMaxMB: 900, tokens: 2),
+ BudgetTier(headroomMaxMB: 1_500, tokens: 4),
+ BudgetTier(headroomMaxMB: .max, tokens: 6),
+ ],
+
+ lighterAlternativeID: nil
+ )
+
+ // MARK: Gemma 4 E4B — 重量, 42 layers (KV cache 大, 更紧)
+
+ public static let gemma4_e4b = ModelRuntimeProfile(
+ thinkingMarker: "<|think|>",
+
+ // 单次输出 token 上限
+ // 实测验证 @1500: 1.0*1200=1200 vs 旧 tier 1280 (基本持平)
+ // @1700: 1.0*1400=1400; @2000: 1.0*1700=1700→cap 1500
+ textOutputBudget: LinearBudgetFormula(
+ safetyMarginMB: 300, tokensPerMB: 1.0, minTokens: 256, maxTokens: 1_500
+ ),
+
+ // thinking 单独放更紧的系数
+ thinkingOutputBudget: LinearBudgetFormula(
+ safetyMarginMB: 300, tokensPerMB: 0.85, minTokens: 192, maxTokens: 1_280
+ ),
+
+ // 多模态: E4B vision 激活内存大, 必须保留细分 tier
+ multimodalOutputTiers: [
+ MultimodalTier(headroomMaxMB: 500, imageSoftTokenCap: 48, maxOutputTokens: 120),
+ MultimodalTier(headroomMaxMB: 700, imageSoftTokenCap: 64, maxOutputTokens: 200),
+ MultimodalTier(headroomMaxMB: 900, imageSoftTokenCap: 80, maxOutputTokens: 340),
+ MultimodalTier(headroomMaxMB: 1_100, imageSoftTokenCap: 96, maxOutputTokens: 512),
+ MultimodalTier(headroomMaxMB: 1_300, imageSoftTokenCap: 128, maxOutputTokens: 512),
+ MultimodalTier(headroomMaxMB: .max, imageSoftTokenCap: 160, maxOutputTokens: 512),
+ ],
+ multimodalCriticalHeadroomMB: 320,
+
+ historyDepthTiers: [
+ BudgetTier(headroomMaxMB: 700, tokens: 0),
+ BudgetTier(headroomMaxMB: 1_100, tokens: 2),
+ BudgetTier(headroomMaxMB: 1_700, tokens: 4),
+ BudgetTier(headroomMaxMB: .max, tokens: 6),
+ ],
+
+ lighterAlternativeID: "gemma-4-e2b-it-4bit"
+ )
+
+ // MARK: - Lookup
+
+ /// 根据 model.id 查 profile
+ public static func profile(for modelID: String) -> ModelRuntimeProfile? {
+ switch modelID {
+ case "gemma-4-e2b-it-4bit": return gemma4_e2b
+ case "gemma-4-e4b-it-4bit": return gemma4_e4b
+ default: return nil
+ }
+ }
+}
diff --git a/LLM/MLX/Core/GPULifecycle.swift b/LLM/MLX/Core/GPULifecycle.swift
new file mode 100644
index 0000000..2f0b2d4
--- /dev/null
+++ b/LLM/MLX/Core/GPULifecycle.swift
@@ -0,0 +1,90 @@
+import Foundation
+#if canImport(UIKit)
+import UIKit
+#endif
+
+// MARK: - MLXLocalLLMService GPU + app lifecycle extension
+//
+// iPhone 禁止后台继续跑 Metal compute, 否则 jetsam 直接 kill。
+// 这个 extension 监听 UIApplication 生命周期通知, 当 app 即将进入非活动状态时
+// 立即取消当前的 generate/load task, 并把 foregroundGPUAllowed flag 置 false。
+// ensureForegroundGPUExecution() 在关键路径前调用, 阻止任何后台提交。
+
+extension MLXLocalLLMService {
+
+ func ensureForegroundGPUExecution() async throws {
+ #if canImport(UIKit)
+ let isActive = await MainActor.run {
+ UIApplication.shared.applicationState == .active
+ }
+ setForegroundGPUAllowed(isActive)
+ guard isActive else {
+ throw MLXError.gpuExecutionRequiresForeground
+ }
+ #endif
+ }
+
+ 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
+ }
+
+ func handleApplicationLeavingForeground() {
+ setForegroundGPUAllowed(false)
+ cancelled = true
+ currentGenerationTask?.cancel()
+ currentLoadTask?.cancel()
+ }
+
+ func setForegroundGPUAllowed(_ allowed: Bool) {
+ foregroundStateLock.lock()
+ foregroundGPUAllowed = allowed
+ foregroundStateLock.unlock()
+ }
+
+ func isForegroundGPUAllowed() -> Bool {
+ foregroundStateLock.lock()
+ let allowed = foregroundGPUAllowed
+ foregroundStateLock.unlock()
+ return allowed
+ }
+}
diff --git a/LLM/MLX/MLXTokenizersLoader.swift b/LLM/MLX/Core/MLXTokenizersLoader.swift
similarity index 100%
rename from LLM/MLX/MLXTokenizersLoader.swift
rename to LLM/MLX/Core/MLXTokenizersLoader.swift
diff --git a/LLM/MLX/Core/MemoryStats.swift b/LLM/MLX/Core/MemoryStats.swift
new file mode 100644
index 0000000..b72aded
--- /dev/null
+++ b/LLM/MLX/Core/MemoryStats.swift
@@ -0,0 +1,31 @@
+import Foundation
+import Darwin
+
+// MARK: - MemoryStats
+//
+// task_vm_info 包装。从 MLXLocalLLMService.swift L733-745 原样迁移,
+// 额外提供 headroomMB 便捷访问。
+
+enum MemoryStats {
+
+ /// (footprint MB, jetsam limit MB) via task_info.
+ static func footprintMB() -> (Double, Double) {
+ var info = task_vm_info_data_t()
+ var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size)
+ let kr = withUnsafeMutablePointer(to: &info) {
+ $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
+ task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
+ }
+ }
+ guard kr == KERN_SUCCESS else { return (0, 0) }
+ let footprint = Double(info.phys_footprint) / 1_048_576
+ let limit = Double(info.limit_bytes_remaining) / 1_048_576 + footprint
+ return (footprint, limit)
+ }
+
+ /// 当前可用内存 headroom (MB), 用于所有 budget 计算。
+ static var headroomMB: Int {
+ let (footprint, limit) = footprintMB()
+ return max(0, Int(limit - footprint))
+ }
+}
diff --git a/LLM/MLX/Core/RuntimeBudgets.swift b/LLM/MLX/Core/RuntimeBudgets.swift
new file mode 100644
index 0000000..66d1b27
--- /dev/null
+++ b/LLM/MLX/Core/RuntimeBudgets.swift
@@ -0,0 +1,172 @@
+import Foundation
+import MLXLMCommon
+
+// MARK: - RuntimeBudgets
+//
+// 纯函数 budget 计算, 零模型 ID 字符串, 零全局状态。
+// 输入: ModelRuntimeProfile + headroom + 少量上下文标志。
+// 输出: 各类 budget struct。
+//
+// 文本/思考 budget 现在使用 LinearBudgetFormula 连续公式 (2026-04-08 重构),
+// 多模态和 historyDepth 仍走 tier 表 (它们的语义不适合线性化)。
+
+// MARK: - Budget 返回类型
+
+public struct TextBudget: Sendable, Equatable {
+ public let maxOutputTokens: Int
+ public let headroomMB: Int
+}
+
+public struct ThinkingBudget: Sendable, Equatable {
+ public let maxOutputTokens: Int
+ public let headroomMB: Int
+}
+
+public struct MultimodalBudget: Sendable, Equatable {
+ public let imageSoftTokenCap: Int?
+ public let maxOutputTokens: Int
+ public let headroomMB: Int
+}
+
+// MARK: - RuntimeBudgets
+
+public enum RuntimeBudgets {
+
+ // MARK: 通用 tier 查表 (仅 historyDepth 用)
+
+ /// 整数离散映射: 找第一个 headroom < tier.headroomMaxMB 的档。
+ /// 最后一档通常用 .max 兜底。
+ static func lookup(_ tiers: [BudgetTier], headroom: Int) -> Int {
+ for tier in tiers where headroom < tier.headroomMaxMB {
+ return tier.tokens
+ }
+ return tiers.last?.tokens ?? 0
+ }
+
+ static func lookupMultimodal(_ tiers: [MultimodalTier], headroom: Int) -> MultimodalTier? {
+ for tier in tiers where headroom < tier.headroomMaxMB {
+ return tier
+ }
+ return tiers.last
+ }
+
+ // MARK: 文本输出 (线性公式)
+
+ public static func text(
+ profile: ModelRuntimeProfile,
+ headroom: Int,
+ enabled: Bool
+ ) -> TextBudget? {
+ guard enabled else { return nil }
+ return TextBudget(
+ maxOutputTokens: profile.textOutputBudget.evaluate(headroom: headroom),
+ headroomMB: headroom
+ )
+ }
+
+ // MARK: Thinking 输出 (线性公式)
+
+ public static func thinking(
+ profile: ModelRuntimeProfile,
+ headroom: Int,
+ enabled: Bool
+ ) -> ThinkingBudget? {
+ guard enabled else { return nil }
+ return ThinkingBudget(
+ maxOutputTokens: profile.thinkingOutputBudget.evaluate(headroom: headroom),
+ headroomMB: headroom
+ )
+ }
+
+ // MARK: 多模态输出
+
+ /// 对应原 dynamicMultimodalBudget(hasImages:hasAudio:)。
+ /// - 无图无音: 返回 nil
+ /// - headroom <= multimodalCriticalHeadroomMB (且该字段 > 0): throw MLXError.multimodalMemoryRisk
+ /// - 其他情况: 按 tier 表返回
+ public static func multimodal(
+ profile: ModelRuntimeProfile,
+ headroom: Int,
+ hasImages: Bool,
+ hasAudio: Bool,
+ modelDisplayName: String,
+ fallbackRecommendation: String
+ ) throws -> MultimodalBudget? {
+ guard hasImages || hasAudio else { return nil }
+
+ // 严格下限保护 (原 L849 E4B 专属, 通过 critical=0 关闭 E2B)
+ if profile.multimodalCriticalHeadroomMB > 0,
+ headroom <= profile.multimodalCriticalHeadroomMB {
+ throw MLXError.multimodalMemoryRisk(
+ model: modelDisplayName,
+ headroomMB: headroom,
+ recommendation: fallbackRecommendation
+ )
+ }
+
+ let tier = lookupMultimodal(profile.multimodalOutputTiers, headroom: headroom)
+ // 原代码: hasImages ? cap : nil。tier 里存的是"有图时用什么 cap"。
+ let imageSoftCap = hasImages ? tier?.imageSoftTokenCap : nil
+ // tier 理论上不会为空, 兜底取最后一档的 maxOutputTokens 是保守行为
+ let maxOut = tier?.maxOutputTokens ?? profile.multimodalOutputTiers.last?.maxOutputTokens ?? 0
+
+ return MultimodalBudget(
+ imageSoftTokenCap: imageSoftCap,
+ maxOutputTokens: maxOut,
+ headroomMB: headroom
+ )
+ }
+
+ // adjustedTextOutputTokens 已在 2026-04-08 删除。
+ //
+ // 它原本基于"prompt + output 必须 fit 在总序列预算里, 所以 output 必须扣减
+ // prepared"的设计。但 chunked prefill (windowSize=256) 让 prepared 长度
+ // 对峰值内存几乎无影响 (实测 prepared 290 → 3319 之间, footprint Δ 完全
+ // 不相关), 这个扣减成了 false positive 截断的根源。
+ //
+ // 现在的内存安全网完全靠 textOutputBudget / thinkingOutputBudget 两个公式,
+ // 它们已经是 headroom 的函数, 内存吃紧时自动收紧。无需再做 prepared 扣减。
+ //
+ // 如果将来发现真的需要"context window 限制" (而不是内存限制), 应该独立
+ // 表达 (比如 ModelRuntimeProfile.contextWindowTokens), 不要复活旧的
+ // sequence budget 概念。
+
+ // MARK: 安全 history 深度
+
+ /// 对应原 safeHistoryDepth (L755-778)。
+ public static func safeHistoryDepth(
+ profile: ModelRuntimeProfile,
+ headroom: Int
+ ) -> Int {
+ lookup(profile.historyDepthTiers, headroom: headroom)
+ }
+
+ // MARK: Thinking 检测
+
+ /// 对应原 isThinkingEnabled(for:) (L901-924)。
+ /// 本次 Phase 1 不在 validator 覆盖范围 (纯字符串匹配, 无数值风险)。
+ public static func isThinkingEnabled(
+ input: UserInput,
+ profile: ModelRuntimeProfile
+ ) -> Bool {
+ if let enabled = input.additionalContext?["enable_thinking"] as? Bool, enabled {
+ return true
+ }
+ guard let marker = profile.thinkingMarker else { return false }
+
+ switch input.prompt {
+ case .text(let text):
+ return text.contains(marker)
+ case .chat(let messages):
+ return messages.contains { $0.content.contains(marker) }
+ case .messages(let messages):
+ return messages.contains { msg in
+ if let s = msg["content"] as? String { return s.contains(marker) }
+ if let arr = msg["content"] as? [[String: any Sendable]] {
+ return arr.contains { ($0["text"] as? String)?.contains(marker) == true }
+ }
+ return false
+ }
+ }
+ }
+}
diff --git a/LLM/MLX/Gemma4AudioFeatureExtractor.swift b/LLM/MLX/Gemma4/Gemma4AudioFeatureExtractor.swift
similarity index 100%
rename from LLM/MLX/Gemma4AudioFeatureExtractor.swift
rename to LLM/MLX/Gemma4/Gemma4AudioFeatureExtractor.swift
diff --git a/LLM/MLX/Gemma4AudioModel.swift b/LLM/MLX/Gemma4/Gemma4AudioModel.swift
similarity index 100%
rename from LLM/MLX/Gemma4AudioModel.swift
rename to LLM/MLX/Gemma4/Gemma4AudioModel.swift
diff --git a/LLM/MLX/Gemma4Config.swift b/LLM/MLX/Gemma4/Gemma4Config.swift
similarity index 100%
rename from LLM/MLX/Gemma4Config.swift
rename to LLM/MLX/Gemma4/Gemma4Config.swift
diff --git a/LLM/MLX/Gemma4LanguageModel.swift b/LLM/MLX/Gemma4/Gemma4LanguageModel.swift
similarity index 100%
rename from LLM/MLX/Gemma4LanguageModel.swift
rename to LLM/MLX/Gemma4/Gemma4LanguageModel.swift
diff --git a/LLM/MLX/Gemma4Model.swift b/LLM/MLX/Gemma4/Gemma4Model.swift
similarity index 77%
rename from LLM/MLX/Gemma4Model.swift
rename to LLM/MLX/Gemma4/Gemma4Model.swift
index 807311e..31a9d27 100644
--- a/LLM/MLX/Gemma4Model.swift
+++ b/LLM/MLX/Gemma4/Gemma4Model.swift
@@ -158,16 +158,18 @@ public class Gemma4Model: Module, VLMModel, KVCacheDimensionProvider {
) throws -> PrepareResult {
let convertedCache = cache.compactMap { $0 as KVCache }
+ // 文本路径: chunked prefill 限制单次 forward 的瞬时激活内存。
+ // 详见 prepareTextChunked 注释。
guard input.image?.pixels != nil || input.audio?.features != nil else {
- let result = languageModel(
- input.text.tokens,
+ return prepareTextChunked(
+ tokens: input.text.tokens,
cache: convertedCache,
- inputsEmbeds: nil,
- perLayerInputs: nil
+ windowSize: windowSize
)
- return .logits(result)
}
+ // 多模态路径: 图像/音频 embedding 必须整体进入,
+ // 它们是固定大小的视觉/音频前缀, 无法天然 chunk。
let inputEmbeddings = getInputEmbeddings(
inputIds: input.text.tokens,
pixelValues: input.image?.pixels,
@@ -185,6 +187,66 @@ public class Gemma4Model: Module, VLMModel, KVCacheDimensionProvider {
return .logits(result)
}
+ /// Chunked prefill: 把 prompt 切成 windowSize 大小的 chunk 逐次 forward,
+ /// 每个 chunk 之后 eval(cache) 强制 materialize KV 并释放 compute graph。
+ ///
+ /// **数学等价性**: 因果注意力 + KV cache 下, chunk 处理与单次 full prefill
+ /// 在最终 KV cache 状态和最后一个 token 的 logits 上完全一致。每个 chunk
+ /// 的 query 通过 cache 看到所有之前 token 的 K/V, 与一次性处理无差别。
+ ///
+ /// **内存收益**: 单 chunk transient = O(windowSize² × layers × hiddenDim)
+ /// 而不是 O(promptLen² × ...)。Gemma 4 E4B (42 层, 8 head, 2560 hidden)
+ /// 在 800 token 单 forward 下瞬时 ~1.7 GB, 加 4.6 GB 已驻留 weights+KV
+ /// 会撞 iPhone jetsam 6.1 GB 上限。256 chunk 下瞬时 ~400 MB, 安全。
+ ///
+ /// **框架层修复**: 不感知 prompt 内容、skill 数量、SKILL.md 格式或任何业务,
+ /// 只关心张量形状与内存预算。任何 prompt 长度都自动适用。
+ private func prepareTextChunked(
+ tokens: MLXArray,
+ cache: [KVCache],
+ windowSize: Int?
+ ) -> PrepareResult {
+ let prefillStepSize = max(windowSize ?? 512, 1)
+ let totalSeqLen = tokens.dim(1)
+
+ // 短 prompt: 单次 forward, 与重构前完全等价。
+ if totalSeqLen <= prefillStepSize {
+ let result = languageModel(
+ tokens,
+ cache: cache,
+ inputsEmbeds: nil,
+ perLayerInputs: nil
+ )
+ return .logits(result)
+ }
+
+ // 长 prompt: 切 chunk 处理。每 chunk 跑 forward → eval(cache) →
+ // discard transient compute graph → 下一 chunk。
+ var processed = 0
+ while processed + prefillStepSize < totalSeqLen {
+ let chunk = tokens[0..., processed ..< (processed + prefillStepSize)]
+ _ = languageModel(
+ chunk,
+ cache: cache,
+ inputsEmbeds: nil,
+ perLayerInputs: nil
+ )
+ eval(cache)
+ processed += prefillStepSize
+ }
+
+ // 最后一段 (<= prefillStepSize tokens): forward 并把 logits 返回给
+ // TokenIterator, 用于采样首个 generated token。
+ let lastChunk = tokens[0..., processed ..< totalSeqLen]
+ let result = languageModel(
+ lastChunk,
+ cache: cache,
+ inputsEmbeds: nil,
+ perLayerInputs: nil
+ )
+ return .logits(result)
+ }
+
public func callAsFunction(_ inputs: MLXArray, cache: [any KVCache]?) -> MLXArray {
let cache = cache?.compactMap { $0 as? KVCache }
let out = languageModel(inputs, cache: cache, inputsEmbeds: nil, perLayerInputs: nil)
diff --git a/LLM/MLX/Gemma4Processor.swift b/LLM/MLX/Gemma4/Gemma4Processor.swift
similarity index 100%
rename from LLM/MLX/Gemma4Processor.swift
rename to LLM/MLX/Gemma4/Gemma4Processor.swift
diff --git a/LLM/MLX/Gemma4Registration.swift b/LLM/MLX/Gemma4/Gemma4Registration.swift
similarity index 100%
rename from LLM/MLX/Gemma4Registration.swift
rename to LLM/MLX/Gemma4/Gemma4Registration.swift
diff --git a/LLM/MLX/Gemma4RoPE.swift b/LLM/MLX/Gemma4/Gemma4RoPE.swift
similarity index 100%
rename from LLM/MLX/Gemma4RoPE.swift
rename to LLM/MLX/Gemma4/Gemma4RoPE.swift
diff --git a/LLM/MLX/Gemma4TextModel.swift b/LLM/MLX/Gemma4/Gemma4TextModel.swift
similarity index 100%
rename from LLM/MLX/Gemma4TextModel.swift
rename to LLM/MLX/Gemma4/Gemma4TextModel.swift
diff --git a/LLM/MLX/Gemma4VisionModel.swift b/LLM/MLX/Gemma4/Gemma4VisionModel.swift
similarity index 100%
rename from LLM/MLX/Gemma4VisionModel.swift
rename to LLM/MLX/Gemma4/Gemma4VisionModel.swift
diff --git a/LLM/MLX/Installation/ModelDownloader.swift b/LLM/MLX/Installation/ModelDownloader.swift
new file mode 100644
index 0000000..ebd69f6
--- /dev/null
+++ b/LLM/MLX/Installation/ModelDownloader.swift
@@ -0,0 +1,304 @@
+import Foundation
+
+// MARK: - Download types (顶层, 从主类嵌套结构提出)
+
+struct DownloadSource: Sendable {
+ let label: String
+ let url: URL
+}
+
+final class DownloadTaskClient: NSObject, URLSessionDownloadDelegate {
+ private let progressHandler: @Sendable (Int64, Int64?) -> Void
+ private var continuation: CheckedContinuation<(URL, URLResponse), Error>?
+ private var downloadedFileURL: URL?
+ private var response: URLResponse?
+ private lazy var session: URLSession = {
+ URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)
+ }()
+ private var task: URLSessionDownloadTask?
+
+ init(progressHandler: @escaping @Sendable (Int64, Int64?) -> Void) {
+ self.progressHandler = progressHandler
+ }
+
+ func start(request: URLRequest) async throws -> (URL, URLResponse) {
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ self.continuation = continuation
+ let task = session.downloadTask(with: request)
+ self.task = task
+ task.resume()
+ }
+ } onCancel: {
+ self.cancel()
+ }
+ }
+
+ func cancel() {
+ task?.cancel()
+ session.invalidateAndCancel()
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didWriteData bytesWritten: Int64,
+ totalBytesWritten: Int64,
+ totalBytesExpectedToWrite: Int64
+ ) {
+ let expected = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : nil
+ progressHandler(totalBytesWritten, expected)
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didFinishDownloadingTo location: URL
+ ) {
+ do {
+ let temporaryFileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension(location.pathExtension.isEmpty ? "tmp" : location.pathExtension)
+
+ if FileManager.default.fileExists(atPath: temporaryFileURL.path) {
+ try FileManager.default.removeItem(at: temporaryFileURL)
+ }
+
+ try FileManager.default.moveItem(at: location, to: temporaryFileURL)
+ downloadedFileURL = temporaryFileURL
+ response = downloadTask.response
+ } catch {
+ downloadedFileURL = nil
+ response = nil
+ continuation?.resume(throwing: error)
+ continuation = nil
+ self.task = nil
+ session.finishTasksAndInvalidate()
+ }
+ }
+
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
+ defer {
+ continuation = nil
+ downloadedFileURL = nil
+ response = nil
+ self.task = nil
+ session.finishTasksAndInvalidate()
+ }
+
+ if let error {
+ continuation?.resume(throwing: error)
+ return
+ }
+
+ guard let downloadedFileURL, let response else {
+ continuation?.resume(throwing: DownloadError.invalidResponse)
+ return
+ }
+
+ continuation?.resume(returning: (downloadedFileURL, response))
+ }
+}
+
+// MARK: - MLXLocalLLMService download extension
+
+extension MLXLocalLLMService {
+
+ func cleanupStalePartialDirectories() {
+ let fm = FileManager.default
+ for model in Self.availableModels {
+ let partialDirectory = ModelPaths.partial(for: model)
+ if fm.fileExists(atPath: partialDirectory.path) {
+ try? fm.removeItem(at: partialDirectory)
+ }
+ }
+ }
+
+ func preferredDownloadSources(for model: BundledModelOption, file: String) -> [DownloadSource] {
+ // HF 系路径: "{repoID}/resolve/main/{file}" — 用于 hf-mirror / huggingface.co
+ let hfPath = "\(model.repositoryID)/resolve/main/\(file)"
+ .addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)?
+ .replacingOccurrences(of: "%2F", with: "/")
+
+ // ModelScope 路径: "{repoID}/resolve/master/{file}" — 结构和 HF 一样, 分支名是 master
+ // 这个端点对 LFS 和非 LFS 文件统一处理 (/api/v1/.../repo?FilePath= 的端点 LFS 会 404)
+ let msPath = "\(model.repositoryID)/resolve/master/\(file)"
+ .addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)?
+ .replacingOccurrences(of: "%2F", with: "/")
+
+ var sources: [DownloadSource] = []
+ // 国内优先: ModelScope 无需 VPN, 已验证 mlx-community/gemma-4-* 在其上完整可下载
+ if let msPath, let url = URL(string: "https://modelscope.cn/models/\(msPath)") {
+ sources.append(.init(label: "modelscope.cn", url: url))
+ }
+ if let hfPath, let url = URL(string: "https://hf-mirror.com/\(hfPath)") {
+ sources.append(.init(label: "hf-mirror.com", url: url))
+ }
+ if let hfPath, let url = URL(string: "https://huggingface.co/\(hfPath)") {
+ sources.append(.init(label: "huggingface.co", url: url))
+ }
+ return sources
+ }
+
+ func downloadFile(
+ with request: URLRequest,
+ progress: @escaping @Sendable (Int64, Int64?) -> Void
+ ) async throws -> (URL, URLResponse) {
+ let client = DownloadTaskClient(progressHandler: progress)
+ return try await client.start(request: request)
+ }
+
+ 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 = ModelPaths.documentsRoot()
+ let finalDirectory = ModelPaths.downloaded(for: model)
+ let partialDirectory = ModelPaths.partial(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() {
+ let downloadSources = preferredDownloadSources(for: model, file: file)
+ guard !downloadSources.isEmpty else {
+ throw DownloadError.invalidURL(file)
+ }
+
+ var downloadedResult: (URL, URLResponse)?
+ var lastError: Error?
+
+ for source in downloadSources {
+ try Task.checkCancellation()
+
+ await MainActor.run {
+ self.modelInstallStates[id] = .downloading(
+ completedFiles: index,
+ totalFiles: totalFiles,
+ currentFile: file
+ )
+ self.modelDownloadMetrics[id] = .init(
+ bytesReceived: 0,
+ totalBytes: nil,
+ bytesPerSecond: nil,
+ sourceLabel: source.label
+ )
+ }
+
+ let request = URLRequest(
+ url: source.url,
+ cachePolicy: .reloadIgnoringLocalCacheData,
+ timeoutInterval: 1800
+ )
+ let startTime = Date()
+
+ do {
+ let result = try await downloadFile(with: request) { received, expected in
+ let elapsed = max(Date().timeIntervalSince(startTime), 0.001)
+ let bytesPerSecond = Double(received) / elapsed
+ Task { @MainActor [weak self] in
+ self?.modelDownloadMetrics[id] = .init(
+ bytesReceived: received,
+ totalBytes: expected,
+ bytesPerSecond: bytesPerSecond,
+ sourceLabel: source.label
+ )
+ }
+ }
+ // HTTP 状态码检查必须在循环内: URLSessionDownloadTask 对 4xx/5xx
+ // 也会成功调 didFinishDownloadingTo (下载错误页), 如果只在循环外检查,
+ // 某个 source 返回 404 会直接 throw, 永远不会试下一个 source。
+ guard let http = result.1 as? HTTPURLResponse else {
+ lastError = DownloadError.invalidResponse
+ print("[Downloader] \(source.label) \(file): invalid response, trying next source")
+ continue
+ }
+ guard (200...299).contains(http.statusCode) else {
+ lastError = DownloadError.httpStatus(http.statusCode)
+ print("[Downloader] \(source.label) \(file): HTTP \(http.statusCode), trying next source")
+ continue
+ }
+ downloadedResult = result
+ break
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ lastError = error
+ print("[Downloader] \(source.label) \(file): \(error.localizedDescription), trying next source")
+ }
+ }
+
+ guard let (temporaryURL, _) = downloadedResult else {
+ throw lastError ?? DownloadError.invalidResponse
+ }
+
+ 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.modelDownloadMetrics[id] = nil
+ self.refreshModelInstallStates()
+ }
+ } catch is CancellationError {
+ try? fm.removeItem(at: partialDirectory)
+ await MainActor.run {
+ self.modelInstallStates[id] = .notInstalled
+ self.modelDownloadMetrics[id] = nil
+ self.refreshModelInstallStates()
+ }
+ } catch {
+ try? fm.removeItem(at: partialDirectory)
+ await MainActor.run {
+ self.modelDownloadMetrics[id] = nil
+ self.modelInstallStates[id] = .failed(error.localizedDescription)
+ }
+ }
+
+ await MainActor.run {
+ self.currentDownloadTasks[id] = nil
+ }
+ }
+
+ currentDownloadTasks[id] = task
+ await task.value
+ }
+
+ public func cancelModelDownload(id: String) {
+ guard currentDownloadTasks[id] != nil else { return }
+ currentDownloadTasks[id]?.cancel()
+ }
+}
diff --git a/LLM/MLX/Installation/ModelInstallState.swift b/LLM/MLX/Installation/ModelInstallState.swift
new file mode 100644
index 0000000..51de5ca
--- /dev/null
+++ b/LLM/MLX/Installation/ModelInstallState.swift
@@ -0,0 +1,67 @@
+import Foundation
+
+// MARK: - Install State
+
+public enum ModelInstallState: Equatable, Sendable {
+ case notInstalled
+ case checkingSource
+ case downloading(completedFiles: Int, totalFiles: Int, currentFile: String)
+ case downloaded
+ case bundled
+ case failed(String)
+}
+
+public struct ModelDownloadMetrics: Equatable, Sendable {
+ public let bytesReceived: Int64
+ public let totalBytes: Int64?
+ public let bytesPerSecond: Double?
+ public let sourceLabel: String?
+}
+
+// MARK: - Errors
+
+enum MLXError: LocalizedError {
+ case modelNotLoaded
+ case modelDirectoryMissing(String)
+ case gpuExecutionRequiresForeground
+ case multimodalMemoryRisk(model: String, headroomMB: Int, recommendation: String)
+
+ var errorDescription: String? {
+ switch self {
+ case .modelNotLoaded:
+ return "MLX model not loaded. Call load() first."
+ case .modelDirectoryMissing(let modelName):
+ return "\(modelName) 模型文件不存在,请先在配置页下载或重新安装。"
+ case .gpuExecutionRequiresForeground:
+ return "应用进入后台时,iPhone 不允许继续提交 GPU 推理任务。"
+ case .multimodalMemoryRisk(let model, let headroomMB, let recommendation):
+ return "\(model) 当前剩余内存仅约 \(headroomMB) MB,继续处理图片/音频很可能被系统直接杀掉。\(recommendation)"
+ }
+ }
+}
+
+enum DownloadError: LocalizedError {
+ case invalidURL(String)
+ case invalidResponse
+ case httpStatus(Int)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL(let file):
+ return "无法构造下载链接:\(file)"
+ case .invalidResponse:
+ return "下载源响应无效"
+ case .httpStatus(let statusCode):
+ switch statusCode {
+ case 401, 403:
+ return "下载源拒绝访问(\(statusCode))"
+ case 404:
+ return "模型文件不存在(404)"
+ case 429:
+ return "下载过于频繁,请稍后重试(429)"
+ default:
+ return "下载失败,HTTP \(statusCode)"
+ }
+ }
+ }
+}
diff --git a/LLM/MLX/Installation/ModelInstaller.swift b/LLM/MLX/Installation/ModelInstaller.swift
new file mode 100644
index 0000000..b373e44
--- /dev/null
+++ b/LLM/MLX/Installation/ModelInstaller.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+// MARK: - MLXLocalLLMService install-state extension
+//
+// selectModel / isModelAvailable / installState / refreshModelInstallStates /
+// downloadMetrics。纯 bookkeeping, 不触碰模型权重加载 (那在主类 load() 里)。
+
+extension MLXLocalLLMService {
+
+ public func selectModel(id: String) -> Bool {
+ guard let option = Self.availableModels.first(where: { $0.id == id }),
+ option != selectedModel else {
+ return false
+ }
+
+ selectedModel = option
+ statusMessage = isLoaded
+ ? "已选择 \(option.displayName),准备重新加载..."
+ : "已选择 \(option.displayName),等待加载..."
+ return true
+ }
+
+ public func isModelAvailable(_ model: BundledModelOption) -> Bool {
+ ModelPaths.bundled(for: model) != nil
+ || ModelPaths.hasRequiredFiles(model, at: ModelPaths.downloaded(for: model))
+ }
+
+ public func installState(for model: BundledModelOption) -> ModelInstallState {
+ if ModelPaths.bundled(for: model) != nil {
+ return .bundled
+ }
+ if ModelPaths.hasRequiredFiles(model, at: ModelPaths.downloaded(for: model)) {
+ return .downloaded
+ }
+ return modelInstallStates[model.id] ?? .notInstalled
+ }
+
+ public func refreshModelInstallStates() {
+ cleanupStalePartialDirectories()
+ for model in Self.availableModels {
+ if ModelPaths.bundled(for: model) != nil {
+ modelInstallStates[model.id] = .bundled
+ } else if ModelPaths.hasRequiredFiles(model, at: ModelPaths.downloaded(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
+ }
+ }
+ }
+
+ public func downloadMetrics(for modelID: String) -> ModelDownloadMetrics? {
+ modelDownloadMetrics[modelID]
+ }
+}
diff --git a/LLM/MLX/Installation/ModelPaths.swift b/LLM/MLX/Installation/ModelPaths.swift
new file mode 100644
index 0000000..6b12d2a
--- /dev/null
+++ b/LLM/MLX/Installation/ModelPaths.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+// MARK: - ModelPaths
+//
+// 静态路径解析: bundle 路径优先, 否则落到 Documents/models/。
+// 从 MLXLocalLLMService.swift L278-332 原样迁移。
+
+enum ModelPaths {
+
+ static func resolve(for model: BundledModelOption) -> URL {
+ if let bundled = bundled(for: model) {
+ return bundled
+ }
+ return downloaded(for: model)
+ }
+
+ static func documentsRoot() -> URL {
+ let documentsPath = FileManager.default.urls(
+ for: .documentDirectory,
+ in: .userDomainMask
+ ).first!
+ return documentsPath.appendingPathComponent("models", isDirectory: true)
+ }
+
+ static func downloaded(for model: BundledModelOption) -> URL {
+ documentsRoot().appendingPathComponent(model.directoryName, isDirectory: true)
+ }
+
+ static func partial(for model: BundledModelOption) -> URL {
+ documentsRoot().appendingPathComponent("\(model.directoryName).partial", isDirectory: true)
+ }
+
+ static func bundled(for model: BundledModelOption) -> URL? {
+ guard let resourceURL = Bundle.main.resourceURL else { return nil }
+
+ let directBundleDir = resourceURL.appendingPathComponent(
+ model.directoryName,
+ isDirectory: true
+ )
+ if hasRequiredFiles(model, at: directBundleDir) {
+ return directBundleDir
+ }
+
+ let nestedBundleDir = resourceURL
+ .appendingPathComponent("Models", isDirectory: true)
+ .appendingPathComponent(model.directoryName, isDirectory: true)
+ if hasRequiredFiles(model, at: nestedBundleDir) {
+ return nestedBundleDir
+ }
+
+ return nil
+ }
+
+ static func hasRequiredFiles(_ 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)
+ }
+ }
+}
diff --git a/LLM/MLX/MLXLocalLLMService.swift b/LLM/MLX/MLXLocalLLMService.swift
new file mode 100644
index 0000000..c7e32f4
--- /dev/null
+++ b/LLM/MLX/MLXLocalLLMService.swift
@@ -0,0 +1,683 @@
+import Foundation
+import CoreImage
+import MLX
+import MLXLLM
+import MLXLMCommon
+import MLXVLM
+#if canImport(UIKit)
+import UIKit
+#endif
+
+// MARK: - MLX Local LLM Service
+
+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]
+
+ /// Planner 及其他结构化 JSON 输出场景是否可用。
+ /// false 时 Planner 入口会被跳过(具体降级策略见 architecture-decisions.md ADR-004)。
+ public let supportsStructuredPlanning: Bool
+
+ /// 运行时 budget / thinking / fallback 行为数据。所有 headroom→token 表都在这里,
+ /// 框架层 RuntimeBudgets 只查表, 不判断 model.id。
+ public let runtimeProfile: ModelRuntimeProfile
+
+ // Hashable / Equatable: 仅用 id (ModelRuntimeProfile 不 Hashable)
+ public static func == (lhs: BundledModelOption, rhs: BundledModelOption) -> Bool {
+ lhs.id == rhs.id
+ }
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
+
+/// MLX GPU inference service for Gemma 4.
+/// Forces MLX Metal GPU path — no CPU fallback.
+@Observable
+public class MLXLocalLLMService: LLMEngine {
+ static let availableModels: [BundledModelOption] = [
+ .init(
+ id: "gemma-4-e2b-it-4bit",
+ directoryName: "gemma-4-e2b-it-4bit",
+ 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"
+ ],
+ supportsStructuredPlanning: false,
+ runtimeProfile: MLXModelProfiles.gemma4_e2b
+ ),
+ .init(
+ id: "gemma-4-e4b-it-4bit",
+ directoryName: "gemma-4-e4b-it-4bit",
+ 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"
+ ],
+ supportsStructuredPlanning: true,
+ runtimeProfile: MLXModelProfiles.gemma4_e4b
+ )
+ ]
+ static let defaultModel = availableModels[0]
+
+ // MARK: - State
+
+ public private(set) var isLoaded = false
+ public private(set) var isLoading = false
+ public private(set) var isGenerating = false
+ public private(set) var stats = LLMStats()
+ public var statusMessage = "等待加载模型..."
+ public internal(set) var selectedModel = defaultModel
+ public internal(set) var loadedModel: BundledModelOption?
+ public var modelDisplayName: String { loadedModel?.displayName ?? selectedModel.displayName }
+ public var selectedModelID: String { selectedModel.id }
+ public var loadedModelID: String? { loadedModel?.id }
+ public internal(set) var modelInstallStates: [String: ModelInstallState] = [:]
+ public internal(set) var modelDownloadMetrics: [String: ModelDownloadMetrics] = [:]
+
+ // MARK: - Compatibility Settings
+
+ public var useGPU = true
+ public var samplingTopK: Int = 40
+ public var samplingTopP: Float = 0.95
+ public var samplingTemperature: Float = 1.0
+ public var maxOutputTokens: Int = 4000
+
+ // Internal (not private) so extensions in ModelDownloader/Installer/GPULifecycle
+ // files can read/write these. Not part of the public API.
+ var modelContainer: ModelContainer?
+ var cancelled = false
+ var currentLoadTask: Task?
+ var currentGenerationTask: Task?
+ var currentDownloadTasks: [String: Task] = [:]
+ let foregroundStateLock = NSLock()
+ var foregroundGPUAllowed = true
+ var lifecycleObserverTokens: [NSObjectProtocol] = []
+ var audioCapabilityEnabled = false
+
+ /// Local path to the model directory
+ var modelPath: URL {
+ ModelPaths.resolve(for: selectedModel)
+ }
+
+ // MARK: - Init
+
+ public init(selectedModelID: String? = nil) {
+ if let selectedModelID,
+ let option = Self.availableModels.first(where: { $0.id == selectedModelID }) {
+ 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
+ public convenience init() {
+ self.init(selectedModelID: nil)
+ }
+
+ func loadModel() {
+ currentLoadTask?.cancel()
+ currentLoadTask = Task { [weak self] in
+ guard let self else { return }
+ defer { self.currentLoadTask = nil }
+ do {
+ if self.isLoading {
+ return
+ }
+ try await load()
+ try await warmup()
+ } catch is CancellationError {
+ await MainActor.run {
+ if self.statusMessage.hasPrefix("正在加载") || self.statusMessage.hasPrefix("正在初始化") {
+ self.statusMessage = "已取消模型切换"
+ }
+ }
+ } catch {
+ 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.localizedDescription)")
+ }
+ }
+ }
+
+ func generateStream(
+ prompt: String,
+ images: [CIImage] = [],
+ audios: [UserInput.Audio] = [],
+ onToken: @escaping (String) -> Void,
+ onComplete: @escaping (Result) -> Void
+ ) {
+ Task {
+ var fullResponse = ""
+
+ do {
+ for try await token in generateStream(prompt: prompt, images: images, audios: audios) {
+ fullResponse += token
+ await MainActor.run {
+ onToken(token)
+ }
+ }
+
+ await MainActor.run {
+ onComplete(.success(fullResponse))
+ }
+ } catch {
+ await MainActor.run {
+ onComplete(.failure(error))
+ }
+ }
+ }
+ }
+
+ func generateStream(
+ chat: [Chat.Message],
+ onToken: @escaping (String) -> Void,
+ onComplete: @escaping (Result) -> Void
+ ) {
+ Task {
+ var fullResponse = ""
+
+ do {
+ for try await token in generateStream(chat: chat) {
+ fullResponse += token
+ await MainActor.run {
+ onToken(token)
+ }
+ }
+
+ await MainActor.run {
+ onComplete(.success(fullResponse))
+ }
+ } catch {
+ await MainActor.run {
+ onComplete(.failure(error))
+ }
+ }
+ }
+ }
+
+ func generateStream(
+ chat: [Chat.Message],
+ additionalContext: [String: any Sendable]?,
+ onToken: @escaping (String) -> Void,
+ onComplete: @escaping (Result) -> Void
+ ) {
+ Task {
+ var fullResponse = ""
+
+ do {
+ for try await token in generateStream(chat: chat, additionalContext: additionalContext) {
+ fullResponse += token
+ await MainActor.run {
+ onToken(token)
+ }
+ }
+
+ await MainActor.run {
+ onComplete(.success(fullResponse))
+ }
+ } catch {
+ await MainActor.run {
+ onComplete(.failure(error))
+ }
+ }
+ }
+ }
+
+ // MARK: - LLMEngine Protocol
+
+ public func load() async throws {
+ if isLoading {
+ return
+ }
+ let model = selectedModel
+ let path = ModelPaths.resolve(for: model)
+ isLoading = true
+ defer {
+ isLoading = false
+ }
+ statusMessage = "正在初始化模型..."
+ await Gemma4Registration.setAudioCapabilityEnabled(audioCapabilityEnabled)
+ await Gemma4Registration.register()
+
+ guard ModelPaths.hasRequiredFiles(model, at: path) else {
+ throw MLXError.modelDirectoryMissing(model.displayName)
+ }
+
+ statusMessage = "正在加载 \(model.displayName)..."
+ let loadStart = CFAbsoluteTimeGetCurrent()
+ print("[MLX] load capability — audio=\(audioCapabilityEnabled ? 1 : 0)")
+
+ // ── Memory diagnostics (read before load) ──────────────────────────────
+ let physMB = Double(ProcessInfo.processInfo.physicalMemory) / 1_048_576
+ let (footprintBefore, limitBefore) = MemoryStats.footprintMB()
+ print("[MEM] Physical RAM: \(Int(physMB)) MB")
+ print("[MEM] Before load — footprint: \(Int(footprintBefore)) MB, jetsam limit: \(Int(limitBefore)) MB")
+ print("[MEM] MLX before — active: \(MLX.GPU.activeMemory / 1_048_576) MB, cache: \(MLX.GPU.cacheMemory / 1_048_576) MB")
+
+ let container = try await VLMModelFactory.shared.loadContainer(
+ from: path,
+ using: MLXTokenizersLoader()
+ )
+
+ try Task.checkCancellation()
+ self.modelContainer = container
+ self.isLoaded = true
+ self.loadedModel = model
+
+ // ── Memory diagnostics (read after load) ───────────────────────────────
+ let (footprintAfter, _) = MemoryStats.footprintMB()
+ print("[MEM] After load — footprint: \(Int(footprintAfter)) MB")
+ print("[MEM] MLX after — active: \(MLX.GPU.activeMemory / 1_048_576) MB, cache: \(MLX.GPU.cacheMemory / 1_048_576) MB")
+
+ let elapsed = (CFAbsoluteTimeGetCurrent() - loadStart) * 1000
+ stats.loadTimeMs = elapsed
+ statusMessage = "模型已就绪 ✅ (\(Int(elapsed))ms)"
+
+ print("[MLX] Model loaded in \(Int(elapsed))ms — backend: mlx-gpu — model: \(model.displayName)")
+ }
+
+ private func ensureAudioCapability(hasAudio: Bool) async throws {
+ guard hasAudio != audioCapabilityEnabled || !isLoaded || modelContainer == nil else {
+ return
+ }
+
+ audioCapabilityEnabled = hasAudio
+ print("[MLX] capability switch requested — audio=\(hasAudio ? 1 : 0)")
+
+ if isLoaded || modelContainer != nil {
+ await prepareForReload(cancelCurrentGeneration: false, cancelCurrentLoad: false)
+ }
+
+ try await load()
+ }
+
+ /// 当前可用内存 headroom(MB)。Agent 用来动态调整 history 深度。
+ public var availableHeadroomMB: Int {
+ MemoryStats.headroomMB
+ }
+
+ /// 根据当前剩余内存推荐安全的 history 深度(消息条数)。
+ /// Chunked prefill (LLM/MLX/Gemma4Model.swift) 把单次 forward 的 transient
+ /// 内存峰值钉死在 chunk² (windowSize=256), 不再随总序列长度线性增长。
+ /// 因此 history 可以放更多: KV cache 增量是每 token ~14KB × layers, 几条
+ /// 历史消息只多几十 MB, 远低于现在 ~1GB 的稳定 headroom。
+ public var safeHistoryDepth: Int {
+ let profile = (loadedModel ?? selectedModel).runtimeProfile
+ return RuntimeBudgets.safeHistoryDepth(profile: profile, headroom: availableHeadroomMB)
+ }
+
+
+ public func warmup() async throws {
+ // Warmup skipped for E2B.
+ //
+ // E2B has 26 layers (E4B has 42). Running MLXLMCommon.generate() for the first time
+ // triggers Metal JIT shader compilation across all unique kernel variants
+ // (attention, MLP, PLE, RoPE ...). This compilation adds a temporary
+ // memory spike on top of the already-loaded 4.9 GB weights, which pushes
+ // the process past the jetsam limit on iPhone 17 Pro Max.
+ //
+ // Skipping warmup means the first user inference compiles shaders lazily
+ // (first response is ~2-3s slower) but avoids the OOM kill on startup.
+ print("[MLX] Warmup skipped — shaders will compile on first inference")
+ statusMessage = "模型已就绪 ✅"
+ }
+
+ public func generateStream(
+ prompt: String,
+ images: [CIImage],
+ audios: [UserInput.Audio]
+ ) -> AsyncThrowingStream {
+ let input: UserInput
+ if images.isEmpty, audios.isEmpty {
+ input = UserInput(prompt: prompt)
+ } else {
+ input = UserInput(
+ chat: [
+ .user(
+ prompt,
+ images: images.map { .ciImage($0) },
+ audios: audios
+ )
+ ]
+ )
+ }
+ return generateStream(input: input, isMultimodal: !images.isEmpty || !audios.isEmpty)
+ }
+
+ public func generateStream(chat: [Chat.Message]) -> AsyncThrowingStream {
+ generateStream(chat: chat, additionalContext: nil)
+ }
+
+ public func generateStream(
+ chat: [Chat.Message],
+ additionalContext: [String: any Sendable]?
+ ) -> AsyncThrowingStream {
+ let input = UserInput(chat: chat, additionalContext: additionalContext)
+ let hasMedia = !input.images.isEmpty || !input.audios.isEmpty
+ return generateStream(input: input, isMultimodal: hasMedia)
+ }
+
+
+ private func currentMultimodalFallbackRecommendation() -> String {
+ let currentModel = loadedModel ?? selectedModel
+ if let lighterID = currentModel.runtimeProfile.lighterAlternativeID,
+ let lighter = Self.availableModels.first(where: { $0.id == lighterID }) {
+ if isModelAvailable(lighter) {
+ return "如仍失败,可手动切换到 \(lighter.displayName) 处理图片或音频。"
+ }
+ return "如仍失败,可先下载并手动切换到 \(lighter.displayName)。"
+ }
+ return "如仍失败,请改用更轻量的模型处理图片或音频。"
+ }
+
+
+ private func generateStream(
+ input: UserInput,
+ isMultimodal: Bool
+ ) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = Task { [weak self] in
+ guard let self else {
+ continuation.finish(throwing: MLXError.modelNotLoaded)
+ return
+ }
+ do {
+ try await self.ensureAudioCapability(hasAudio: !input.audios.isEmpty)
+ } catch {
+ continuation.finish(throwing: error)
+ return
+ }
+
+ guard let container = modelContainer else {
+ continuation.finish(throwing: MLXError.modelNotLoaded)
+ return
+ }
+
+ // Free Metal buffers cached from previous inference before
+ // allocating the new computation graph. Critical on low-headroom devices:
+ // the follow-up prompt is longer than the first inference,
+ // and without clearing, residual cache + new activations
+ // exceed the 6GB jetsam limit on iPhone.
+ //
+ // NOTE: No prompt prefix caching is implemented. Every generateStream call
+ // rebuilds the full KV cache from scratch. Prompt prefix stability does NOT
+ // improve inference latency in the current architecture. Any future proposal
+ // to restructure prompts for "cache hit rate" should first implement actual
+ // prefix caching before assuming performance gains.
+ MLX.GPU.clearCache()
+
+ let currentModel = self.loadedModel ?? self.selectedModel
+ let profile = currentModel.runtimeProfile
+ let headroom = MemoryStats.headroomMB
+
+ let thinkingEnabled = RuntimeBudgets.isThinkingEnabled(input: input, profile: profile)
+ let textBudget = RuntimeBudgets.text(profile: profile, headroom: headroom, enabled: !isMultimodal)
+ let runtimeBudget: MultimodalBudget?
+ do {
+ runtimeBudget = try RuntimeBudgets.multimodal(
+ profile: profile,
+ headroom: headroom,
+ hasImages: !input.images.isEmpty,
+ hasAudio: !input.audios.isEmpty,
+ modelDisplayName: currentModel.displayName,
+ fallbackRecommendation: "请关闭后台应用后重试,或减少附件数量。\(self.currentMultimodalFallbackRecommendation())"
+ )
+ } catch {
+ continuation.finish(throwing: error)
+ return
+ }
+ let thinkingBudget = RuntimeBudgets.thinking(profile: profile, headroom: headroom, enabled: thinkingEnabled)
+
+ if let runtimeBudget {
+ print(
+ "[MEM] multimodal runtime budget — model=\(currentModel.displayName), "
+ + "headroom=\(headroom) MB, "
+ + "imageSoftTokenCap=\(runtimeBudget.imageSoftTokenCap.map(String.init) ?? "n/a"), "
+ + "maxOutputTokens=\(runtimeBudget.maxOutputTokens), "
+ + "audio=\(!input.audios.isEmpty ? 1 : 0)"
+ )
+ }
+ if let thinkingBudget {
+ print("[MEM] thinking runtime budget — model=\(currentModel.displayName), headroom=\(headroom) MB, maxOutputTokens=\(thinkingBudget.maxOutputTokens)")
+ }
+ if let textBudget {
+ print("[MEM] text runtime budget — model=\(currentModel.displayName), headroom=\(headroom) MB, maxOutputTokens=\(textBudget.maxOutputTokens)")
+ }
+
+ // TODO: 抽出 ModelAdapter 协议后移除 Gemma 专属耦合
+ Gemma4Processor.setRuntimeImageSoftTokenCap(runtimeBudget?.imageSoftTokenCap)
+ defer {
+ Gemma4Processor.setRuntimeImageSoftTokenCap(nil)
+ }
+ let effectiveMaxOutputTokens: Int = {
+ let multimodalCap = isMultimodal
+ ? runtimeBudget?.maxOutputTokens ?? profile.multimodalOutputTiers.last?.maxOutputTokens ?? maxOutputTokens
+ : maxOutputTokens
+ let thinkingCap = thinkingBudget?.maxOutputTokens ?? maxOutputTokens
+ let textCap = textBudget?.maxOutputTokens ?? maxOutputTokens
+ return min(maxOutputTokens, multimodalCap, thinkingCap, textCap)
+ }()
+ var resolvedMaxOutputTokens = effectiveMaxOutputTokens
+
+ self.isGenerating = true
+ self.cancelled = false
+ let genStart = CFAbsoluteTimeGetCurrent()
+ var firstTokenTime: Double? = nil
+ var tokenCount = 0
+ var hitTokenCap = false
+
+ let (fp, _) = MemoryStats.footprintMB()
+ print("[MEM] generateStream start — footprint: \(Int(fp)) MB, MLX active: \(MLX.GPU.activeMemory / 1_048_576) MB")
+
+ do {
+ try await self.ensureForegroundGPUExecution()
+ _ = try await container.perform { context in
+ try await self.ensureForegroundGPUExecution()
+ if isMultimodal {
+ print("[VLM] multimodal budget — maxOutputTokens=\(resolvedMaxOutputTokens)")
+ } else if thinkingEnabled {
+ print("[LLM] thinking budget — baseMaxOutputTokens=\(resolvedMaxOutputTokens)")
+ }
+ let preparedInput = try await context.processor.prepare(input: input)
+ let preparedSequenceLength = preparedInput.text.tokens.dim(1)
+ if isMultimodal {
+ print("[VLM] prepared sequence length=\(preparedSequenceLength)")
+ } else {
+ // 不再基于 prepared 长度二次扣减 output 上限。
+ // chunked prefill 让 prepared 长度对峰值内存几乎无影响,
+ // resolvedMaxOutputTokens 已由 textOutputBudget(headroom)
+ // 决定, 直接使用即可。
+ print(
+ "[LLM] prepared sequence length=\(preparedSequenceLength), "
+ + "outputCap=\(resolvedMaxOutputTokens)"
+ )
+ }
+ try await self.ensureForegroundGPUExecution()
+
+ // prefillStepSize: chunked prefill window. 把长 prompt
+ // 切成 256 token / chunk 处理,每个 chunk 跑完调 eval(cache)
+ // 释放 compute graph,单 chunk transient 峰值控制在 ~400 MB
+ // 以内,避免长 prompt(800+ tokens)单次 forward 把 attention
+ // workspace 推过 iPhone 6.1 GB jetsam 上限。
+ //
+ // MLX 默认 512 是为桌面 Apple Silicon 调的;iPhone E4B
+ // (42 layers) 在 512 chunk 下 transient 峰值约 1.7 GB,
+ // 加上 4.6 GB 已驻留 weights+KV 会撞 jetsam。
+ // 这是框架层修复,与 prompt 内容、skill 数量、SKILL.md
+ // 格式完全无关。
+ return try MLXLMCommon.generate(
+ input: preparedInput,
+ parameters: .init(
+ maxTokens: resolvedMaxOutputTokens,
+ temperature: samplingTemperature,
+ topP: samplingTopP,
+ topK: samplingTopK,
+ prefillStepSize: 256
+ ),
+ context: context
+ ) { tokens in
+ if self.cancelled || !self.isForegroundGPUAllowed() {
+ return .stop
+ }
+
+ tokenCount = tokens.count
+ if firstTokenTime == nil {
+ firstTokenTime = (CFAbsoluteTimeGetCurrent() - genStart) * 1000
+ }
+
+ // Stream the latest token
+ if let lastToken = tokens.last {
+ let text = context.tokenizer.decode(tokenIds: [lastToken])
+ continuation.yield(text)
+ }
+
+ // Multimodal path uses a tighter generation budget on iPhone.
+ // If we hit the cap, signal truncation so the caller can append a notice.
+ if tokens.count >= resolvedMaxOutputTokens {
+ hitTokenCap = true
+ return .stop
+ }
+ return .more
+ }
+ }
+
+ let elapsed = CFAbsoluteTimeGetCurrent() - genStart
+ self.stats.ttftMs = firstTokenTime ?? 0
+ self.stats.tokensPerSec = elapsed > 0
+ ? Double(tokenCount) / elapsed : 0
+ self.stats.totalTokens = tokenCount
+
+ print(
+ "[MLX] Generated \(tokenCount) tokens in \(String(format: "%.1f", elapsed))s"
+ )
+ print(
+ "[MLX] TTFT: \(String(format: "%.0f", self.stats.ttftMs))ms, "
+ + "Speed: \(String(format: "%.1f", self.stats.tokensPerSec)) tok/s")
+
+ // 推理结束后立即释放 Metal activation 缓存,
+ // 确保下一轮有最大可用 headroom。
+ MLX.GPU.clearCache()
+ let (fpEnd, _) = MemoryStats.footprintMB()
+ print("[MEM] generateStream end — footprint: \(Int(fpEnd)) MB, headroom: \(self.availableHeadroomMB) MB")
+
+ // If we hit the token cap mid-sentence, append a visible notice.
+ // This makes truncation explicit rather than silently dropping content.
+ if hitTokenCap {
+ let isChinese = Locale.preferredLanguages.contains { $0.hasPrefix("zh") }
+ let modeLabel = isChinese
+ ? (thinkingEnabled ? "思考" : "输出")
+ : (thinkingEnabled ? "Thinking" : "Output")
+ continuation.yield("\n\n> ⚠️ \(modeLabel)已达单次输出上限(\(resolvedMaxOutputTokens) tokens),内容可能不完整。")
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+
+ self.isGenerating = false
+ self.currentGenerationTask = nil
+ }
+
+ currentGenerationTask = task
+ continuation.onTermination = { [weak self] _ in
+ task.cancel()
+ if self?.currentGenerationTask?.isCancelled == true {
+ self?.currentGenerationTask = nil
+ }
+ }
+ }
+ }
+
+ public func cancel() {
+ cancelled = true
+ currentGenerationTask?.cancel()
+ currentLoadTask?.cancel()
+ }
+
+ public func prepareForReload(
+ cancelCurrentGeneration: Bool = true,
+ cancelCurrentLoad: Bool = true
+ ) async {
+ cancelled = true
+ if cancelCurrentGeneration {
+ currentGenerationTask?.cancel()
+ }
+ if cancelCurrentLoad {
+ currentLoadTask?.cancel()
+ }
+
+ while isGenerating || isLoading {
+ try? await Task.sleep(nanoseconds: 50_000_000)
+ }
+
+ unload(
+ cancelCurrentGeneration: cancelCurrentGeneration,
+ cancelCurrentLoad: cancelCurrentLoad
+ )
+ MLX.GPU.clearCache()
+ try? await Task.sleep(nanoseconds: 150_000_000)
+ }
+
+ public func unload() {
+ unload(cancelCurrentGeneration: true, cancelCurrentLoad: true)
+ }
+
+ public func unload(
+ cancelCurrentGeneration: Bool = true,
+ cancelCurrentLoad: Bool = true
+ ) {
+ if cancelCurrentGeneration {
+ currentGenerationTask?.cancel()
+ }
+ if cancelCurrentLoad {
+ currentLoadTask?.cancel()
+ }
+ modelContainer = nil
+ isLoaded = false
+ isLoading = false
+ isGenerating = false
+ loadedModel = nil
+ cancelled = false
+ stats = LLMStats()
+ stats.backend = "mlx-gpu"
+ MLX.GPU.clearCache()
+ statusMessage = "模型已卸载"
+ print("[MLX] Model unloaded")
+ }
+}
+
diff --git a/LLM/MLXLocalLLMService.swift b/LLM/MLXLocalLLMService.swift
deleted file mode 100644
index e490aec..0000000
--- a/LLM/MLXLocalLLMService.swift
+++ /dev/null
@@ -1,1471 +0,0 @@
-import Foundation
-import CoreImage
-import MLX
-import MLXLLM
-import MLXLMCommon
-import MLXVLM
-#if canImport(UIKit)
-import UIKit
-#endif
-
-// MARK: - MLX Local LLM Service
-
-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)
-}
-
-public struct ModelDownloadMetrics: Equatable, Sendable {
- public let bytesReceived: Int64
- public let totalBytes: Int64?
- public let bytesPerSecond: Double?
- public let sourceLabel: String?
-}
-
-/// MLX GPU inference service for Gemma 4.
-/// Forces MLX Metal GPU path — no CPU fallback.
-@Observable
-public class MLXLocalLLMService: LLMEngine {
- static let availableModels: [BundledModelOption] = [
- .init(
- id: "gemma-4-e2b-it-4bit",
- directoryName: "gemma-4-e2b-it-4bit",
- 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",
- 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]
- private static let multimodalMaxOutputTokens = 512
- private static let e4bThinkingMaxOutputTokens = 1_024
- private static let e2bThinkingMaxOutputTokens = 768
- private static let e4bModelID = "gemma-4-e4b-it-4bit"
- private static let e2bModelID = "gemma-4-e2b-it-4bit"
- private static let e4bMultimodalCriticalHeadroomMB = 320
-
- private struct MultimodalRuntimeBudget {
- let imageSoftTokenCap: Int?
- let maxOutputTokens: Int
- let headroomMB: Int
- }
-
- private struct ThinkingRuntimeBudget {
- let maxOutputTokens: Int
- let headroomMB: Int
- }
-
- private struct TextRuntimeBudget {
- let maxOutputTokens: Int
- let headroomMB: Int
- }
-
- // MARK: - State
-
- public private(set) var isLoaded = false
- public private(set) var isLoading = false
- public private(set) var isGenerating = false
- public private(set) var stats = LLMStats()
- public var statusMessage = "等待加载模型..."
- public private(set) var selectedModel = defaultModel
- public private(set) var loadedModel: BundledModelOption?
- 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] = [:]
- public private(set) var modelDownloadMetrics: [String: ModelDownloadMetrics] = [:]
-
- // MARK: - Compatibility Settings
-
- public var useGPU = true
- public var samplingTopK: Int = 40
- public var samplingTopP: Float = 0.95
- public var samplingTemperature: Float = 1.0
- public var maxOutputTokens: Int = 4000
-
- private var modelContainer: ModelContainer?
- private var cancelled = false
- private var currentLoadTask: Task?
- private var currentGenerationTask: Task?
- private var currentDownloadTasks: [String: Task] = [:]
- private let foregroundStateLock = NSLock()
- private var foregroundGPUAllowed = true
- private var lifecycleObserverTokens: [NSObjectProtocol] = []
- private var audioCapabilityEnabled = false
-
- private struct DownloadSource: Sendable {
- let label: String
- let url: URL
- }
-
- private final class DownloadTaskClient: NSObject, URLSessionDownloadDelegate {
- private let progressHandler: @Sendable (Int64, Int64?) -> Void
- private var continuation: CheckedContinuation<(URL, URLResponse), Error>?
- private var downloadedFileURL: URL?
- private var response: URLResponse?
- private lazy var session: URLSession = {
- URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)
- }()
- private var task: URLSessionDownloadTask?
-
- init(progressHandler: @escaping @Sendable (Int64, Int64?) -> Void) {
- self.progressHandler = progressHandler
- }
-
- func start(request: URLRequest) async throws -> (URL, URLResponse) {
- try await withTaskCancellationHandler {
- try await withCheckedThrowingContinuation { continuation in
- self.continuation = continuation
- let task = session.downloadTask(with: request)
- self.task = task
- task.resume()
- }
- } onCancel: {
- self.cancel()
- }
- }
-
- func cancel() {
- task?.cancel()
- session.invalidateAndCancel()
- }
-
- func urlSession(
- _ session: URLSession,
- downloadTask: URLSessionDownloadTask,
- didWriteData bytesWritten: Int64,
- totalBytesWritten: Int64,
- totalBytesExpectedToWrite: Int64
- ) {
- let expected = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : nil
- progressHandler(totalBytesWritten, expected)
- }
-
- func urlSession(
- _ session: URLSession,
- downloadTask: URLSessionDownloadTask,
- didFinishDownloadingTo location: URL
- ) {
- do {
- let temporaryFileURL = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString)
- .appendingPathExtension(location.pathExtension.isEmpty ? "tmp" : location.pathExtension)
-
- if FileManager.default.fileExists(atPath: temporaryFileURL.path) {
- try FileManager.default.removeItem(at: temporaryFileURL)
- }
-
- try FileManager.default.moveItem(at: location, to: temporaryFileURL)
- downloadedFileURL = temporaryFileURL
- response = downloadTask.response
- } catch {
- downloadedFileURL = nil
- response = nil
- continuation?.resume(throwing: error)
- continuation = nil
- self.task = nil
- session.finishTasksAndInvalidate()
- }
- }
-
- func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
- defer {
- continuation = nil
- downloadedFileURL = nil
- response = nil
- self.task = nil
- session.finishTasksAndInvalidate()
- }
-
- if let error {
- continuation?.resume(throwing: error)
- return
- }
-
- guard let downloadedFileURL, let response else {
- continuation?.resume(throwing: DownloadError.invalidResponse)
- return
- }
-
- continuation?.resume(returning: (downloadedFileURL, response))
- }
- }
-
- /// Local path to the model directory
- private var modelPath: URL {
- Self.resolveModelPath(for: selectedModel)
- }
-
- // MARK: - Init
-
- public init(selectedModelID: String? = nil) {
- if let selectedModelID,
- let option = Self.availableModels.first(where: { $0.id == selectedModelID }) {
- 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
- public convenience init() {
- self.init(selectedModelID: nil)
- }
-
- public func selectModel(id: String) -> Bool {
- guard let option = Self.availableModels.first(where: { $0.id == id }),
- option != selectedModel else {
- return false
- }
-
- selectedModel = option
- statusMessage = isLoaded
- ? "已选择 \(option.displayName),准备重新加载..."
- : "已选择 \(option.displayName),等待加载..."
- return true
- }
-
- private static func resolveModelPath(for model: BundledModelOption) -> URL {
- 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", 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
- }
- }
- }
-
- public func downloadMetrics(for modelID: String) -> ModelDownloadMetrics? {
- modelDownloadMetrics[modelID]
- }
-
- 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 preferredDownloadSources(for model: BundledModelOption, file: String) -> [DownloadSource] {
- let encodedPath = "\(model.repositoryID)/resolve/main/\(file)"
- .addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)?
- .replacingOccurrences(of: "%2F", with: "/")
-
- guard let encodedPath else { return [] }
-
- return [
- .init(label: "hf-mirror.com", url: URL(string: "https://hf-mirror.com/\(encodedPath)")!),
- .init(label: "huggingface.co", url: URL(string: "https://huggingface.co/\(encodedPath)")!)
- ]
- }
-
- private func downloadFile(
- with request: URLRequest,
- progress: @escaping @Sendable (Int64, Int64?) -> Void
- ) async throws -> (URL, URLResponse) {
- let client = DownloadTaskClient(progressHandler: progress)
- return try await client.start(request: request)
- }
-
- 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() {
- let downloadSources = preferredDownloadSources(for: model, file: file)
- guard !downloadSources.isEmpty else {
- throw DownloadError.invalidURL(file)
- }
-
- var downloadedResult: (URL, URLResponse)?
- var lastError: Error?
-
- for source in downloadSources {
- try Task.checkCancellation()
-
- await MainActor.run {
- self.modelInstallStates[id] = .downloading(
- completedFiles: index,
- totalFiles: totalFiles,
- currentFile: file
- )
- self.modelDownloadMetrics[id] = .init(
- bytesReceived: 0,
- totalBytes: nil,
- bytesPerSecond: nil,
- sourceLabel: source.label
- )
- }
-
- let request = URLRequest(
- url: source.url,
- cachePolicy: .reloadIgnoringLocalCacheData,
- timeoutInterval: 1800
- )
- let startTime = Date()
-
- do {
- let result = try await downloadFile(with: request) { received, expected in
- let elapsed = max(Date().timeIntervalSince(startTime), 0.001)
- let bytesPerSecond = Double(received) / elapsed
- Task { @MainActor [weak self] in
- self?.modelDownloadMetrics[id] = .init(
- bytesReceived: received,
- totalBytes: expected,
- bytesPerSecond: bytesPerSecond,
- sourceLabel: source.label
- )
- }
- }
- downloadedResult = result
- break
- } catch is CancellationError {
- throw CancellationError()
- } catch {
- lastError = error
- }
- }
-
- guard let (temporaryURL, response) = downloadedResult else {
- throw lastError ?? DownloadError.invalidResponse
- }
-
- 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.modelDownloadMetrics[id] = nil
- self.refreshModelInstallStates()
- }
- } catch is CancellationError {
- try? fm.removeItem(at: partialDirectory)
- await MainActor.run {
- self.modelInstallStates[id] = .notInstalled
- self.modelDownloadMetrics[id] = nil
- self.refreshModelInstallStates()
- }
- } catch {
- try? fm.removeItem(at: partialDirectory)
- await MainActor.run {
- self.modelDownloadMetrics[id] = nil
- self.modelInstallStates[id] = .failed(error.localizedDescription)
- }
- }
-
- await MainActor.run {
- self.currentDownloadTasks[id] = nil
- }
- }
-
- currentDownloadTasks[id] = task
- await task.value
- }
-
- public func cancelModelDownload(id: String) {
- guard currentDownloadTasks[id] != nil else { return }
- currentDownloadTasks[id]?.cancel()
- }
-
- func loadModel() {
- currentLoadTask?.cancel()
- currentLoadTask = Task { [weak self] in
- guard let self else { return }
- defer { self.currentLoadTask = nil }
- do {
- if self.isLoading {
- return
- }
- try await load()
- try await warmup()
- } catch is CancellationError {
- await MainActor.run {
- if self.statusMessage.hasPrefix("正在加载") || self.statusMessage.hasPrefix("正在初始化") {
- self.statusMessage = "已取消模型切换"
- }
- }
- } catch {
- 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.localizedDescription)")
- }
- }
- }
-
- func generateStream(
- prompt: String,
- images: [CIImage] = [],
- audios: [UserInput.Audio] = [],
- onToken: @escaping (String) -> Void,
- onComplete: @escaping (Result) -> Void
- ) {
- Task {
- var fullResponse = ""
-
- do {
- for try await token in generateStream(prompt: prompt, images: images, audios: audios) {
- fullResponse += token
- await MainActor.run {
- onToken(token)
- }
- }
-
- await MainActor.run {
- onComplete(.success(fullResponse))
- }
- } catch {
- await MainActor.run {
- onComplete(.failure(error))
- }
- }
- }
- }
-
- func generateStream(
- chat: [Chat.Message],
- onToken: @escaping (String) -> Void,
- onComplete: @escaping (Result) -> Void
- ) {
- Task {
- var fullResponse = ""
-
- do {
- for try await token in generateStream(chat: chat) {
- fullResponse += token
- await MainActor.run {
- onToken(token)
- }
- }
-
- await MainActor.run {
- onComplete(.success(fullResponse))
- }
- } catch {
- await MainActor.run {
- onComplete(.failure(error))
- }
- }
- }
- }
-
- func generateStream(
- chat: [Chat.Message],
- additionalContext: [String: any Sendable]?,
- onToken: @escaping (String) -> Void,
- onComplete: @escaping (Result) -> Void
- ) {
- Task {
- var fullResponse = ""
-
- do {
- for try await token in generateStream(chat: chat, additionalContext: additionalContext) {
- fullResponse += token
- await MainActor.run {
- onToken(token)
- }
- }
-
- await MainActor.run {
- onComplete(.success(fullResponse))
- }
- } catch {
- await MainActor.run {
- onComplete(.failure(error))
- }
- }
- }
- }
-
- // MARK: - LLMEngine Protocol
-
- public func load() async throws {
- if isLoading {
- return
- }
- let model = selectedModel
- let path = Self.resolveModelPath(for: model)
- isLoading = true
- defer {
- isLoading = false
- }
- statusMessage = "正在初始化模型..."
- await Gemma4Registration.setAudioCapabilityEnabled(audioCapabilityEnabled)
- await Gemma4Registration.register()
-
- guard Self.hasRequiredFiles(for: model, at: path) else {
- throw MLXError.modelDirectoryMissing(model.displayName)
- }
-
- statusMessage = "正在加载 \(model.displayName)..."
- let loadStart = CFAbsoluteTimeGetCurrent()
- print("[MLX] load capability — audio=\(audioCapabilityEnabled ? 1 : 0)")
-
- // ── Memory diagnostics (read before load) ──────────────────────────────
- let physMB = Double(ProcessInfo.processInfo.physicalMemory) / 1_048_576
- let (footprintBefore, limitBefore) = appMemoryFootprintMB()
- print("[MEM] Physical RAM: \(Int(physMB)) MB")
- print("[MEM] Before load — footprint: \(Int(footprintBefore)) MB, jetsam limit: \(Int(limitBefore)) MB")
- print("[MEM] MLX before — active: \(MLX.GPU.activeMemory / 1_048_576) MB, cache: \(MLX.GPU.cacheMemory / 1_048_576) MB")
-
- let container = try await VLMModelFactory.shared.loadContainer(
- from: path,
- using: MLXTokenizersLoader()
- )
-
- try Task.checkCancellation()
- self.modelContainer = container
- self.isLoaded = true
- self.loadedModel = model
-
- // ── Memory diagnostics (read after load) ───────────────────────────────
- let (footprintAfter, _) = appMemoryFootprintMB()
- print("[MEM] After load — footprint: \(Int(footprintAfter)) MB")
- print("[MEM] MLX after — active: \(MLX.GPU.activeMemory / 1_048_576) MB, cache: \(MLX.GPU.cacheMemory / 1_048_576) MB")
-
- let elapsed = (CFAbsoluteTimeGetCurrent() - loadStart) * 1000
- stats.loadTimeMs = elapsed
- statusMessage = "模型已就绪 ✅ (\(Int(elapsed))ms)"
-
- print("[MLX] Model loaded in \(Int(elapsed))ms — backend: mlx-gpu — model: \(model.displayName)")
- }
-
- private func ensureAudioCapability(hasAudio: Bool) async throws {
- guard hasAudio != audioCapabilityEnabled || !isLoaded || modelContainer == nil else {
- return
- }
-
- audioCapabilityEnabled = hasAudio
- print("[MLX] capability switch requested — audio=\(hasAudio ? 1 : 0)")
-
- if isLoaded || modelContainer != nil {
- await prepareForReload(cancelCurrentGeneration: false, cancelCurrentLoad: false)
- }
-
- try await load()
- }
-
- /// Returns (footprint MB, jetsam limit MB) via task_info.
- private func appMemoryFootprintMB() -> (Double, Double) {
- var info = task_vm_info_data_t()
- var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size)
- let kr = withUnsafeMutablePointer(to: &info) {
- $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
- task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
- }
- }
- guard kr == KERN_SUCCESS else { return (0, 0) }
- let footprint = Double(info.phys_footprint) / 1_048_576
- let limit = Double(info.limit_bytes_remaining) / 1_048_576 + footprint
- return (footprint, limit)
- }
-
- /// 当前可用内存 headroom(MB)。Agent 用来动态调整 history 深度。
- public var availableHeadroomMB: Int {
- let (footprint, limit) = appMemoryFootprintMB()
- return max(0, Int(limit - footprint))
- }
-
- /// 根据当前剩余内存推荐安全的 history 深度(消息条数)。
- /// E4B 在 1 GB 左右 headroom 时对文本 KV cache 非常敏感,因此需要更激进地收紧历史。
- public var safeHistoryDepth: Int {
- let h = availableHeadroomMB
- let model = loadedModel ?? Self.availableModels.first(where: { $0.id == selectedModel.id })
-
- if model?.id == Self.e4bModelID {
- switch h {
- case 1_700...: return 4
- case 1_100..<1_700: return 2
- default: return 0
- }
- } else {
- switch h {
- case 1_500...: return 4
- case 900..<1_500: return 2
- default: return 0
- }
- }
- }
-
-
- public func warmup() async throws {
- // Warmup skipped for E2B.
- //
- // E2B has 26 layers (E4B has 42). Running MLXLMCommon.generate() for the first time
- // triggers Metal JIT shader compilation across all unique kernel variants
- // (attention, MLP, PLE, RoPE ...). This compilation adds a temporary
- // memory spike on top of the already-loaded 4.9 GB weights, which pushes
- // the process past the jetsam limit on iPhone 17 Pro Max.
- //
- // Skipping warmup means the first user inference compiles shaders lazily
- // (first response is ~2-3s slower) but avoids the OOM kill on startup.
- print("[MLX] Warmup skipped — shaders will compile on first inference")
- statusMessage = "模型已就绪 ✅"
- }
-
- public func generateStream(
- prompt: String,
- images: [CIImage],
- audios: [UserInput.Audio]
- ) -> AsyncThrowingStream {
- let input: UserInput
- if images.isEmpty, audios.isEmpty {
- input = UserInput(prompt: prompt)
- } else {
- input = UserInput(
- chat: [
- .user(
- prompt,
- images: images.map { .ciImage($0) },
- audios: audios
- )
- ]
- )
- }
- return generateStream(input: input, isMultimodal: !images.isEmpty || !audios.isEmpty)
- }
-
- public func generateStream(chat: [Chat.Message]) -> AsyncThrowingStream {
- generateStream(chat: chat, additionalContext: nil)
- }
-
- public func generateStream(
- chat: [Chat.Message],
- additionalContext: [String: any Sendable]?
- ) -> AsyncThrowingStream {
- let input = UserInput(chat: chat, additionalContext: additionalContext)
- let hasMedia = !input.images.isEmpty || !input.audios.isEmpty
- return generateStream(input: input, isMultimodal: hasMedia)
- }
-
- private func dynamicMultimodalBudget(
- hasImages: Bool,
- hasAudio: Bool
- ) throws -> MultimodalRuntimeBudget? {
- guard hasImages || hasAudio else { return nil }
- guard let model = loadedModel ?? Self.availableModels.first(where: { $0.id == selectedModel.id }) else {
- return nil
- }
- let headroom = availableHeadroomMB
-
- guard model.id == Self.e4bModelID else {
- return MultimodalRuntimeBudget(
- imageSoftTokenCap: hasImages ? 160 : nil,
- maxOutputTokens: Self.multimodalMaxOutputTokens,
- headroomMB: headroom
- )
- }
-
- guard headroom > Self.e4bMultimodalCriticalHeadroomMB else {
- let recommendation = "请关闭后台应用后重试,或减少附件数量。\(currentMultimodalFallbackRecommendation())"
- throw MLXError.multimodalMemoryRisk(
- model: model.displayName,
- headroomMB: headroom,
- recommendation: recommendation
- )
- }
-
- let imageSoftTokenCap: Int?
- let maxOutputTokens: Int
-
- // Budget calibration (E4B, measured on-device):
- // Actual activation cost ≈ 1 MB/output token
- // (observed: 160 tokens consumed 167 MB headroom = 5311-5144 MB footprint delta)
- // Safety margin formula: headroom − (imageSoftTokens × 2 MB) − (maxOut × 1 MB) > 300 MB
- switch headroom {
- case ..<500:
- imageSoftTokenCap = hasImages ? 48 : nil
- maxOutputTokens = 120 // was 64 — 48×2 + 120×1 + 300 = 516, fits <500 boundary
- case ..<700:
- imageSoftTokenCap = hasImages ? 64 : nil
- maxOutputTokens = 200 // was 128 — 64×2 + 200 + 300 = 628 < 700 ✓
- case ..<900:
- imageSoftTokenCap = hasImages ? 80 : nil
- maxOutputTokens = 340 // was 192 — 80×2 + 340 + 300 = 800 < 900 ✓
- case ..<1_100:
- imageSoftTokenCap = hasImages ? 96 : nil
- maxOutputTokens = Self.multimodalMaxOutputTokens // 512: 96×2 + 512 + 300 = 1004 < 1100 ✓
- case ..<1_300:
- imageSoftTokenCap = hasImages ? 128 : nil
- maxOutputTokens = Self.multimodalMaxOutputTokens
- default:
- imageSoftTokenCap = hasImages ? 160 : nil
- maxOutputTokens = Self.multimodalMaxOutputTokens
- }
-
- print(
- "[MEM] multimodal runtime budget — model=\(model.displayName), "
- + "headroom=\(headroom) MB, "
- + "imageSoftTokenCap=\(imageSoftTokenCap.map(String.init) ?? "n/a"), "
- + "maxOutputTokens=\(maxOutputTokens), "
- + "audio=\(hasAudio ? 1 : 0)"
- )
-
- return MultimodalRuntimeBudget(
- imageSoftTokenCap: imageSoftTokenCap,
- maxOutputTokens: maxOutputTokens,
- headroomMB: headroom
- )
- }
-
- private func isThinkingEnabled(for input: UserInput) -> Bool {
- if let enabled = input.additionalContext?["enable_thinking"] as? Bool, enabled {
- return true
- }
-
- switch input.prompt {
- case .text(let text):
- return text.contains("<|think|>")
- case .chat(let messages):
- return messages.contains { $0.content.contains("<|think|>") }
- case .messages(let messages):
- return messages.contains { message in
- if let content = message["content"] as? String {
- return content.contains("<|think|>")
- }
- if let content = message["content"] as? [[String: any Sendable]] {
- return content.contains { item in
- (item["text"] as? String)?.contains("<|think|>") == true
- }
- }
- return false
- }
- }
- }
-
- private func dynamicThinkingBudget(
- enabled: Bool
- ) -> ThinkingRuntimeBudget? {
- guard enabled else { return nil }
- guard let model = loadedModel ?? Self.availableModels.first(where: { $0.id == selectedModel.id }) else {
- return nil
- }
-
- let headroom = availableHeadroomMB
- let maxOutputTokens: Int
-
- if model.id == Self.e4bModelID {
- switch headroom {
- case ..<500:
- maxOutputTokens = 128
- case ..<700:
- maxOutputTokens = 192
- case ..<900:
- maxOutputTokens = 256
- case ..<1_100:
- maxOutputTokens = 384
- case ..<1_300:
- maxOutputTokens = 512
- default:
- maxOutputTokens = Self.e4bThinkingMaxOutputTokens
- }
- } else {
- switch headroom {
- case ..<500:
- maxOutputTokens = 192
- case ..<800:
- maxOutputTokens = 384
- case ..<1_200:
- maxOutputTokens = 512
- default:
- maxOutputTokens = Self.e2bThinkingMaxOutputTokens
- }
- }
-
- print(
- "[MEM] thinking runtime budget — model=\(model.displayName), "
- + "headroom=\(headroom) MB, "
- + "maxOutputTokens=\(maxOutputTokens)"
- )
-
- return ThinkingRuntimeBudget(
- maxOutputTokens: maxOutputTokens,
- headroomMB: headroom
- )
- }
-
- private func dynamicTextBudget(
- enabled: Bool
- ) -> TextRuntimeBudget? {
- guard enabled else { return nil }
- guard let model = loadedModel ?? Self.availableModels.first(where: { $0.id == selectedModel.id }) else {
- return nil
- }
-
- let headroom = availableHeadroomMB
- let maxOutputTokens: Int
-
- if model.id == Self.e4bModelID {
- switch headroom {
- case ..<500:
- maxOutputTokens = 256
- case ..<700:
- maxOutputTokens = 384
- case ..<900:
- maxOutputTokens = 512
- case ..<1_100:
- maxOutputTokens = 768
- case ..<1_300:
- maxOutputTokens = 1_024
- default:
- maxOutputTokens = 1_280
- }
- } else {
- switch headroom {
- case ..<500:
- maxOutputTokens = 384
- case ..<800:
- maxOutputTokens = 768
- case ..<1_200:
- maxOutputTokens = 1_024
- default:
- maxOutputTokens = 1_536
- }
- }
-
- print(
- "[MEM] text runtime budget — model=\(model.displayName), "
- + "headroom=\(headroom) MB, "
- + "maxOutputTokens=\(maxOutputTokens)"
- )
-
- return TextRuntimeBudget(
- maxOutputTokens: maxOutputTokens,
- headroomMB: headroom
- )
- }
-
- private func adjustedTextOutputTokens(
- baseMaxOutputTokens: Int,
- preparedSequenceLength: Int,
- thinkingEnabled: Bool
- ) -> Int? {
- guard let model = loadedModel ?? Self.availableModels.first(where: { $0.id == selectedModel.id }) else {
- return nil
- }
-
- let headroom = availableHeadroomMB
- let totalSequenceBudget: Int
-
- if model.id == Self.e4bModelID {
- if thinkingEnabled {
- switch headroom {
- case ..<500:
- totalSequenceBudget = 320
- case ..<700:
- totalSequenceBudget = 448
- case ..<900:
- totalSequenceBudget = 576
- case ..<1_100:
- totalSequenceBudget = 896
- case ..<1_300:
- totalSequenceBudget = 1_152
- default:
- totalSequenceBudget = 1_280
- }
- } else {
- switch headroom {
- case ..<500:
- totalSequenceBudget = 384
- case ..<700:
- totalSequenceBudget = 512
- case ..<900:
- totalSequenceBudget = 640
- case ..<1_100:
- totalSequenceBudget = 768
- case ..<1_300:
- totalSequenceBudget = 896
- default:
- totalSequenceBudget = 1_024
- }
- }
- } else {
- if thinkingEnabled {
- switch headroom {
- case ..<500:
- totalSequenceBudget = 448
- case ..<800:
- totalSequenceBudget = 640
- case ..<1_200:
- totalSequenceBudget = 896
- default:
- totalSequenceBudget = 1_152
- }
- } else {
- switch headroom {
- case ..<500:
- totalSequenceBudget = 512
- case ..<800:
- totalSequenceBudget = 768
- case ..<1_200:
- totalSequenceBudget = 1_024
- default:
- totalSequenceBudget = 1_280
- }
- }
- }
-
- let minimumOutputTokens = thinkingEnabled ? 96 : 128
- let adjustedMaxOutputTokens = min(
- baseMaxOutputTokens,
- max(minimumOutputTokens, totalSequenceBudget - preparedSequenceLength)
- )
-
- print(
- "[MEM] text sequence budget — model=\(model.displayName), "
- + "headroom=\(headroom) MB, "
- + "preparedTokens=\(preparedSequenceLength), "
- + "totalSequenceBudget=\(totalSequenceBudget), "
- + "adjustedMaxOutputTokens=\(adjustedMaxOutputTokens)"
- )
-
- return adjustedMaxOutputTokens
- }
-
- private func currentMultimodalFallbackRecommendation() -> String {
- if let e2b = Self.availableModels.first(where: { $0.id == Self.e2bModelID }) {
- if isModelAvailable(e2b) {
- return "如仍失败,可手动切换到 \(e2b.displayName) 处理图片或音频。"
- }
- return "如仍失败,可先下载并手动切换到 \(e2b.displayName)。"
- } else {
- return "如仍失败,请改用更轻量的模型处理图片或音频。"
- }
- }
-
- private func ensureForegroundGPUExecution() async throws {
- #if canImport(UIKit)
- 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
- ) -> AsyncThrowingStream {
- AsyncThrowingStream { continuation in
- let task = Task { [weak self] in
- guard let self else {
- continuation.finish(throwing: MLXError.modelNotLoaded)
- return
- }
- do {
- try await self.ensureAudioCapability(hasAudio: !input.audios.isEmpty)
- } catch {
- continuation.finish(throwing: error)
- return
- }
-
- guard let container = modelContainer else {
- continuation.finish(throwing: MLXError.modelNotLoaded)
- return
- }
-
- // Free Metal buffers cached from previous inference before
- // allocating the new computation graph. Critical on low-headroom devices:
- // the follow-up prompt is longer than the first inference,
- // and without clearing, residual cache + new activations
- // exceed the 6GB jetsam limit on iPhone.
- MLX.GPU.clearCache()
-
- let thinkingEnabled = self.isThinkingEnabled(for: input)
- let textBudget = self.dynamicTextBudget(enabled: !isMultimodal)
- let runtimeBudget: MultimodalRuntimeBudget?
- do {
- runtimeBudget = try self.dynamicMultimodalBudget(
- hasImages: !input.images.isEmpty,
- hasAudio: !input.audios.isEmpty
- )
- } catch {
- continuation.finish(throwing: error)
- return
- }
- let thinkingBudget = self.dynamicThinkingBudget(enabled: thinkingEnabled)
- Gemma4Processor.setRuntimeImageSoftTokenCap(runtimeBudget?.imageSoftTokenCap)
- defer {
- Gemma4Processor.setRuntimeImageSoftTokenCap(nil)
- }
- let effectiveMaxOutputTokens: Int = {
- let multimodalCap = isMultimodal
- ? runtimeBudget?.maxOutputTokens ?? Self.multimodalMaxOutputTokens
- : maxOutputTokens
- let thinkingCap = thinkingBudget?.maxOutputTokens ?? maxOutputTokens
- let textCap = textBudget?.maxOutputTokens ?? maxOutputTokens
- return min(maxOutputTokens, multimodalCap, thinkingCap, textCap)
- }()
- var resolvedMaxOutputTokens = effectiveMaxOutputTokens
-
- self.isGenerating = true
- self.cancelled = false
- let genStart = CFAbsoluteTimeGetCurrent()
- var firstTokenTime: Double? = nil
- var tokenCount = 0
- var hitTokenCap = false
-
- let (fp, _) = appMemoryFootprintMB()
- print("[MEM] generateStream start — footprint: \(Int(fp)) MB, MLX active: \(MLX.GPU.activeMemory / 1_048_576) MB")
-
- do {
- try await self.ensureForegroundGPUExecution()
- _ = try await container.perform { context in
- try await self.ensureForegroundGPUExecution()
- if isMultimodal {
- print("[VLM] multimodal budget — maxOutputTokens=\(resolvedMaxOutputTokens)")
- } else if thinkingEnabled {
- print("[LLM] thinking budget — baseMaxOutputTokens=\(resolvedMaxOutputTokens)")
- }
- let preparedInput = try await context.processor.prepare(input: input)
- if isMultimodal {
- print("[VLM] prepared sequence length=\(preparedInput.text.tokens.dim(1))")
- } else {
- let preparedSequenceLength = preparedInput.text.tokens.dim(1)
- print("[LLM] prepared sequence length=\(preparedSequenceLength)")
- resolvedMaxOutputTokens =
- self.adjustedTextOutputTokens(
- baseMaxOutputTokens: resolvedMaxOutputTokens,
- preparedSequenceLength: preparedSequenceLength,
- thinkingEnabled: thinkingEnabled
- ) ?? resolvedMaxOutputTokens
- if textBudget != nil {
- print("[LLM] text budget — maxOutputTokens=\(resolvedMaxOutputTokens)")
- } else if thinkingEnabled {
- print("[LLM] thinking budget — maxOutputTokens=\(resolvedMaxOutputTokens)")
- }
- }
- try await self.ensureForegroundGPUExecution()
-
- return try MLXLMCommon.generate(
- input: preparedInput,
- parameters: .init(
- maxTokens: resolvedMaxOutputTokens,
- temperature: samplingTemperature,
- topP: samplingTopP,
- topK: samplingTopK
- ),
- context: context
- ) { tokens in
- if self.cancelled || !self.isForegroundGPUAllowed() {
- return .stop
- }
-
- tokenCount = tokens.count
- if firstTokenTime == nil {
- firstTokenTime = (CFAbsoluteTimeGetCurrent() - genStart) * 1000
- }
-
- // Stream the latest token
- if let lastToken = tokens.last {
- let text = context.tokenizer.decode(tokenIds: [lastToken])
- continuation.yield(text)
- }
-
- // Multimodal path uses a tighter generation budget on iPhone.
- // If we hit the cap, signal truncation so the caller can append a notice.
- if tokens.count >= resolvedMaxOutputTokens {
- hitTokenCap = true
- return .stop
- }
- return .more
- }
- }
-
- let elapsed = CFAbsoluteTimeGetCurrent() - genStart
- self.stats.ttftMs = firstTokenTime ?? 0
- self.stats.tokensPerSec = elapsed > 0
- ? Double(tokenCount) / elapsed : 0
- self.stats.totalTokens = tokenCount
-
- print(
- "[MLX] Generated \(tokenCount) tokens in \(String(format: "%.1f", elapsed))s"
- )
- print(
- "[MLX] TTFT: \(String(format: "%.0f", self.stats.ttftMs))ms, "
- + "Speed: \(String(format: "%.1f", self.stats.tokensPerSec)) tok/s")
-
- // 推理结束后立即释放 Metal activation 缓存,
- // 确保下一轮有最大可用 headroom。
- MLX.GPU.clearCache()
- let (fpEnd, _) = appMemoryFootprintMB()
- print("[MEM] generateStream end — footprint: \(Int(fpEnd)) MB, headroom: \(self.availableHeadroomMB) MB")
-
- // If we hit the token cap mid-sentence, append a visible notice.
- // This makes truncation explicit rather than silently dropping content.
- if hitTokenCap {
- let isChinese = Locale.preferredLanguages.contains { $0.hasPrefix("zh") }
- let modeLabel = isChinese
- ? (thinkingEnabled ? "思考" : "输出")
- : (thinkingEnabled ? "Thinking" : "Output")
- continuation.yield("\n\n> ⚠️ \(modeLabel)已达内存安全上限(\(resolvedMaxOutputTokens) tokens),内容可能不完整。")
- }
- continuation.finish()
- } catch {
- continuation.finish(throwing: error)
- }
-
- self.isGenerating = false
- self.currentGenerationTask = nil
- }
-
- currentGenerationTask = task
- continuation.onTermination = { [weak self] _ in
- task.cancel()
- if self?.currentGenerationTask?.isCancelled == true {
- self?.currentGenerationTask = nil
- }
- }
- }
- }
-
- public func cancel() {
- cancelled = true
- currentGenerationTask?.cancel()
- currentLoadTask?.cancel()
- }
-
- public func prepareForReload(
- cancelCurrentGeneration: Bool = true,
- cancelCurrentLoad: Bool = true
- ) async {
- cancelled = true
- if cancelCurrentGeneration {
- currentGenerationTask?.cancel()
- }
- if cancelCurrentLoad {
- currentLoadTask?.cancel()
- }
-
- while isGenerating || isLoading {
- try? await Task.sleep(nanoseconds: 50_000_000)
- }
-
- unload(
- cancelCurrentGeneration: cancelCurrentGeneration,
- cancelCurrentLoad: cancelCurrentLoad
- )
- MLX.GPU.clearCache()
- try? await Task.sleep(nanoseconds: 150_000_000)
- }
-
- public func unload() {
- unload(cancelCurrentGeneration: true, cancelCurrentLoad: true)
- }
-
- public func unload(
- cancelCurrentGeneration: Bool = true,
- cancelCurrentLoad: Bool = true
- ) {
- if cancelCurrentGeneration {
- currentGenerationTask?.cancel()
- }
- if cancelCurrentLoad {
- currentLoadTask?.cancel()
- }
- modelContainer = nil
- isLoaded = false
- isLoading = false
- isGenerating = false
- loadedModel = nil
- cancelled = false
- stats = LLMStats()
- stats.backend = "mlx-gpu"
- MLX.GPU.clearCache()
- statusMessage = "模型已卸载"
- print("[MLX] Model unloaded")
- }
-}
-
-// MARK: - Errors
-
-enum MLXError: LocalizedError {
- case modelNotLoaded
- case modelDirectoryMissing(String)
- case gpuExecutionRequiresForeground
- case multimodalMemoryRisk(model: String, headroomMB: Int, recommendation: String)
-
- var errorDescription: String? {
- switch self {
- case .modelNotLoaded:
- return "MLX model not loaded. Call load() first."
- case .modelDirectoryMissing(let modelName):
- return "\(modelName) 模型文件不存在,请先在配置页下载或重新安装。"
- case .gpuExecutionRequiresForeground:
- return "应用进入后台时,iPhone 不允许继续提交 GPU 推理任务。"
- case .multimodalMemoryRisk(let model, let headroomMB, let recommendation):
- return "\(model) 当前剩余内存仅约 \(headroomMB) MB,继续处理图片/音频很可能被系统直接杀掉。\(recommendation)"
- }
- }
-}
-
-enum DownloadError: LocalizedError {
- case invalidURL(String)
- case invalidResponse
- case httpStatus(Int)
-
- var errorDescription: String? {
- switch self {
- case .invalidURL(let file):
- return "无法构造下载链接:\(file)"
- case .invalidResponse:
- return "下载源响应无效"
- case .httpStatus(let statusCode):
- switch statusCode {
- case 401, 403:
- return "下载源拒绝访问(\(statusCode))"
- case 404:
- return "模型文件不存在(404)"
- case 429:
- return "下载过于频繁,请稍后重试(429)"
- default:
- return "下载失败,HTTP \(statusCode)"
- }
- }
- }
-}
diff --git a/LLM/PromptBuilder.swift b/LLM/PromptBuilder.swift
index f2d1abb..1749c9b 100644
--- a/LLM/PromptBuilder.swift
+++ b/LLM/PromptBuilder.swift
@@ -32,11 +32,58 @@ struct PromptBuilder {
return "\n" + Array(repeating: "<|image|>", count: count).joined(separator: "\n")
}
+ /// 工具参数提取阶段需要的"当前时间锚点"。
+ /// 模型必须知道"现在是何时"才能解析"明天/下午两点"等相对时间表达
+ /// 并写出正确的 ISO 8601 字符串。
+ /// 用本地时区(用户设备时区), 周几用中文,方便中文模型理解。
+ private static func currentTimeAnchorBlock() -> String {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "zh_CN")
+ formatter.timeZone = .current
+ formatter.dateFormat = "yyyy-MM-dd EEEE HH:mm"
+ let now = formatter.string(from: Date())
+ return "当前时间锚点(用于解析\"今天/明天/下午两点\"等相对时间): \(now)"
+ }
+
private static func extractSystemBlock(from prompt: String) -> String {
+ let raw: String
if let turnEnd = prompt.range(of: "\n") {
- return String(prompt[prompt.startIndex...turnEnd.upperBound])
+ raw = String(prompt[prompt.startIndex...turnEnd.upperBound])
+ } else {
+ raw = prompt
}
- return prompt
+ // 在 secondary prompt 的 system block 末尾追加"当前时间锚点"。
+ // 这是 runtime data injection,不感知任何业务:任何调用 extractSystemBlock
+ // 的 prompt builder(load_skill 之后、tool answer、planner 各阶段等)
+ // 自动获得"现在是何时"的上下文,模型才能解析"明天/下午两点"等
+ // 相对时间表达。
+ return injectIntoSystemBlock(raw, extraInstructions: currentTimeAnchorBlock())
+ }
+
+ /// 从一个完整 prompt(由 PromptBuilder.build() 构造)里提取
+ /// "system 块结束之后, 当前 user 消息开始之前"的所有历史 turn 块。
+ ///
+ /// 用途: secondary 推理(load_skill 之后、tool 执行之后、planner 各阶段)
+ /// 自动获得和 first inference 同样的对话历史, 不再是无记忆地只看当前消息。
+ ///
+ /// 实现是纯字符串切片, 不感知任何业务: 切的是 PromptBuilder.build() 自己
+ /// 渲染的 turn 标签结构, 任何 secondary prompt builder 都能受益。
+ private static func extractHistoryBlock(from prompt: String) -> String {
+ guard let systemEnd = prompt.range(of: "\n") else { return "" }
+ let afterSystem = systemEnd.upperBound
+
+ // 找最后一个 "<|turn>user\n" - 那是当前 user message 的开头
+ let searchRange = afterSystem..user\n",
+ options: .backwards,
+ range: searchRange
+ ) else {
+ return ""
+ }
+
+ // 返回 system 结束 ~ 当前 user 开始 之间的所有历史 turn
+ return String(prompt[afterSystem.. String {
let isMultimodalTurn = currentImageCount > 0
var prompt = "<|turn>system\n"
@@ -103,25 +151,46 @@ struct PromptBuilder {
: (systemPrompt ?? defaultSystemPrompt)
// 构建 Skill 概要列表(只列名称 + 一句话描述,不暴露 Tool)
- var skillListText = ""
- for skill in tools {
- skillListText += "- **\(skill.name)**: \(skill.description)\n"
+ // 按 SkillType 分两组, 给模型不同调用规则。
+ let deviceSkills = tools.filter { $0.type == .device }
+ let contentSkills = tools.filter { $0.type == .content }
+ func renderList(_ list: [SkillInfo]) -> String {
+ if list.isEmpty { return "(无)\n" }
+ return list.map { "- **\($0.name)**: \($0.description)" }.joined(separator: "\n") + "\n"
}
+ let deviceListText = renderList(deviceSkills)
+ let contentListText = renderList(contentSkills)
+ // 兼容旧版 SYSPROMPT.md (仅 ___SKILLS___) 的扁平列表
+ let flatListText: String = {
+ var s = ""
+ for skill in tools { s += "- **\(skill.name)**: \(skill.description)\n" }
+ return s
+ }()
if isMultimodalTurn {
prompt += basePrompt
+ } else if basePrompt.contains("___DEVICE_SKILLS___") || basePrompt.contains("___CONTENT_SKILLS___") {
+ // 新版双占位符: 按类别分别注入
+ var resolved = basePrompt
+ resolved = resolved.replacingOccurrences(of: "___DEVICE_SKILLS___", with: deviceListText)
+ resolved = resolved.replacingOccurrences(of: "___CONTENT_SKILLS___", with: contentListText)
+ prompt += resolved
} else if basePrompt.contains("___SKILLS___") {
- // 处理 ___SKILLS___ 占位符
- prompt += basePrompt.replacingOccurrences(of: "___SKILLS___", with: skillListText)
+ // 旧版扁平占位符: 保留向后兼容
+ prompt += basePrompt.replacingOccurrences(of: "___SKILLS___", with: flatListText)
} else {
- // SYSPROMPT.md 不含 ___SKILLS___ 时的兜底:只追加技能列表,不追加指令。
- // 调用规则已在 SYSPROMPT.md 里定义,不在这里硬编。
+ // SYSPROMPT.md 不含任何占位符时的兜底:只追加技能列表,不追加指令。
prompt += basePrompt
if !tools.isEmpty {
- prompt += "\n\n你拥有以下能力(Skill):\n\n" + skillListText
+ prompt += "\n\n你拥有以下能力(Skill):\n\n" + flatListText
}
}
+ // 仅全量注入(无匹配命中)时,提示模型可通过 list_skills 发现更多能力
+ if showListSkillsHint && !isMultimodalTurn {
+ prompt += "\n如果以上列出的能力都不匹配用户需求,可以调用 list_skills 查询更多能力:\n\n{\"name\": \"list_skills\", \"arguments\": {\"query\": \"用户需求描述\"}}\n\n"
+ }
+
if enableThinking && !isMultimodalTurn {
prompt += "\n\n" + thinkingLanguageInstruction
}
@@ -207,38 +276,58 @@ struct PromptBuilder {
originalPrompt: String,
userQuestion: String,
skillInstructions: String,
+ availableTools: [String],
currentImageCount: Int = 0,
forceResponse: Bool = false
) -> String {
+ // Scaffold (T2 progressive disclosure):
+ // 显式告诉模型当前 skill 有哪些工具可调 (只列名字, 不列 schema —
+ // schema 属于 T3 暴露)。空列表时明确说"无工具", 防止模型幻觉编造
+ // 不存在的工具名 (例如 "professional_translator")。
+ let toolBlock: String
+ if availableTools.isEmpty {
+ toolBlock = """
+ 当前 Skill **没有任何可调用的工具**。
+ 按 Skill 指令直接给最终答案正文文本, 禁止输出 。
+ """
+ } else {
+ let listText = availableTools.map { "- `\($0)`" }.joined(separator: "\n")
+ toolBlock = """
+ 当前 Skill 可调用的工具 (只允许这些名字):
+ \(listText)
+ 如果需要操作, 输出 {"name": "<上面列表中的名字>", "arguments": {...}}。
+ 其他名字一律视为非法, 不要凭空编造。
+ 如果不需要工具, 直接给最终答案正文文本。
+ """
+ }
+
let systemBlock = extractSystemBlock(from: originalPrompt)
let systemInstructions = injectIntoSystemBlock(
systemBlock,
extraInstructions: """
- 对于当前这一个用户问题,你已经加载了所需的 Skill 指令。
- 不要再次调用 `load_skill`。
- 如果需要执行设备内操作,直接调用对应工具;如果不需要工具,直接回答。
+ 对于当前这一个用户问题, 你已经加载了所需的 Skill 指令。不要再次调用 `load_skill`。
- 已加载的 Skill 指令:
+ 已加载的 Skill 指令:
\(skillInstructions)
+
+ \(toolBlock)
"""
)
var prompt = systemInstructions
+ prompt += extractHistoryBlock(from: originalPrompt)
prompt += """
<|turn>user
- 用户问题:
+ 用户问题:
\(userQuestion)\(imagePromptSuffix(count: currentImageCount))
- 处理这个请求时,严格按以下顺序执行:
- 1. 使用已经加载的 Skill 指令,不要再次调用 `load_skill`。
- 2. 如果需要设备内操作,直接调用对应工具。
- 3. 如果工具已经成功返回,或者已经足够回答,就只输出最终结果。
-
- 不要让用户去“打开 skill”或“使用某个能力”,需要的话你自己直接调用工具。
- 你必须避免输出任何中间思考、状态更新、字段名、JSON 模板、代码块或规划草稿。
+ 按上面的 Skill 指令处理这个请求。
+ - 不要再次调用 load_skill。
+ - 不要让用户去"打开 skill"或"使用某个能力"。
+ - 不要输出中间思考/状态更新/字段名/JSON 模板/代码块/规划草稿。
\(forceResponse
- ? "你的下一条回复必须是以下两种之一:1. 一个 `...` 2. 直接给用户的最终回答正文。禁止输出空白。"
- : "如果需要工具就直接调用;如果已经足够回答,就直接给出最终答案正文。")
+ ? "你必须输出非空内容: 要么是合法的 ..., 要么是最终答案正文。"
+ : "如果不需要工具就直接给最终答案正文; 如果需要工具按上面规定的工具名调用。")
<|turn>model
@@ -257,7 +346,7 @@ struct PromptBuilder {
) -> String {
let systemBlock = extractSystemBlock(from: originalPrompt)
- return systemBlock + """
+ return systemBlock + extractHistoryBlock(from: originalPrompt) + """
<|turn>user
用户原始问题:
\(userQuestion)\(imagePromptSuffix(count: currentImageCount))
@@ -299,7 +388,7 @@ struct PromptBuilder {
"""
)
- return systemInstructions + """
+ return systemInstructions + extractHistoryBlock(from: originalPrompt) + """
<|turn>user
用户问题:
\(userQuestion)\(imagePromptSuffix(count: currentImageCount))
@@ -314,8 +403,9 @@ struct PromptBuilder {
3. 不要输出 Markdown、代码块、解释、字段草稿或多余文字。
4. 可选字段如果没有,就直接省略。
5. 时间字段必须转换成 ISO 8601,例如 `2026-04-07T20:00:00`。
- 6. 如果缺少必填参数,输出:
- {"_needs_clarification":"请补充缺少的信息"}
+ 6. 如果缺少必填参数,只输出一个 JSON object: {"_needs_clarification": "..."}。
+ "..." 部分必须用一句完整中文,直接陈述当前请求缺哪个具体参数,
+ 不能含尖括号、不能含占位符、不能复制本规则的任何字面文本。
<|turn>model
@@ -342,7 +432,7 @@ struct PromptBuilder {
"""
)
- return systemInstructions + """
+ return systemInstructions + extractHistoryBlock(from: originalPrompt) + """
<|turn>user
用户问题:
\(userQuestion)\(imagePromptSuffix(count: currentImageCount))
@@ -362,8 +452,219 @@ struct PromptBuilder {
4. `arguments` 里只保留当前工具需要的参数;没有的可选参数直接省略。
5. 不要输出 Markdown、代码块、解释、草稿或多余文字。
6. 时间字段必须转换成 ISO 8601,例如 `2026-04-07T20:00:00`。
- 7. 如果缺少执行所需的关键信息,输出:
- {"_needs_clarification":"请补充缺少的信息"}
+ 7. 如果缺少执行所需的关键信息,只输出一个 JSON object: {"_needs_clarification": "..."}。
+ "..." 部分必须用一句完整中文,直接陈述当前请求缺哪个具体信息,
+ 不能含尖括号、不能含占位符、不能复制本规则的任何字面文本。
+
+ <|turn>model
+
+ """
+ }
+
+ // MARK: - Planner v3 Prompt Builders
+
+ static func buildSkillSelectionPrompt(
+ originalPrompt: String,
+ userQuestion: String,
+ availableSkillsSummary: String,
+ recentContextSummary: String = "",
+ currentImageCount: Int = 0
+ ) -> String {
+ let systemBlock = extractSystemBlock(from: originalPrompt)
+ let systemInstructions = injectIntoSystemBlock(
+ systemBlock,
+ extraInstructions: """
+ 对于当前这一个用户问题,你现在只负责判断需要哪些 Skill。
+ 不要调用 `load_skill`,不要输出 ``,不要直接回答用户问题。
+
+ 可用的 Skill 与工具如下:
+ \(availableSkillsSummary)
+ """
+ )
+
+ let recentContextBlock: String
+ if recentContextSummary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ recentContextBlock = ""
+ } else {
+ recentContextBlock = """
+
+ 最近已知的工具结果摘要(可作为当前规划的上下文):
+ \(recentContextSummary)
+ """
+ }
+
+ return systemInstructions + extractHistoryBlock(from: originalPrompt) + """
+ <|turn>user
+ 用户问题:
+ \(userQuestion)\(imagePromptSuffix(count: currentImageCount))\(recentContextBlock)
+
+ 请输出一个 JSON object,格式必须是:
+ {
+ "required_skills": ["skill_id_1", "skill_id_2"],
+ "needs_clarification": null
+ }
+
+ 严格遵守以下要求:
+ 1. 只输出 JSON object,不要输出 Markdown、代码块、解释或多余文字。
+ 2. `required_skills` 里的每一项必须严格等于上面"可用的 Skill 与工具"段落中列出的 skill id 字符串本身,不能填该 skill 下属的工具名,不能自己拼接,不能翻译。
+ 3. 如果任务需要先获取一个结果、再交给另一个 Skill 继续处理,涉及到的所有 Skill 都要列出来,不要只写最终那一个。
+ 4. 如果"最近已知的工具结果摘要"已经提供了部分信息,也要据此补全后续需要的 Skill,不要漏掉。
+ 5. 如果用户需求不需要任何 Skill,返回空数组 `[]`。
+ 6. 如果无法判断需要哪些 Skill,返回:
+ {"required_skills": [], "needs_clarification": "请说明具体需要什么帮助"}
+
+ <|turn>model
+
+ """
+ }
+
+ static func buildSkillPlanningPrompt(
+ originalPrompt: String,
+ userQuestion: String,
+ availableSkillsSummary: String,
+ recentContextSummary: String = "",
+ currentImageCount: Int = 0
+ ) -> String {
+ let systemBlock = extractSystemBlock(from: originalPrompt)
+ let systemInstructions = injectIntoSystemBlock(
+ systemBlock,
+ extraInstructions: """
+ 对于当前这一个用户问题,你现在只负责生成完整的执行计划。
+ 不要调用 `load_skill`,不要输出 ``,不要直接回答用户问题。
+
+ 本阶段只允许使用以下已选中的 Skill 与工具:
+ \(availableSkillsSummary)
+ """
+ )
+
+ let recentContextBlock: String
+ if recentContextSummary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ recentContextBlock = ""
+ } else {
+ recentContextBlock = """
+
+ 最近已知的工具结果摘要(可作为当前规划的上下文):
+ \(recentContextSummary)
+ """
+ }
+
+ return systemInstructions + extractHistoryBlock(from: originalPrompt) + """
+ <|turn>user
+ 用户问题:
+ \(userQuestion)\(imagePromptSuffix(count: currentImageCount))\(recentContextBlock)
+
+ 请输出一个 JSON object,格式必须是:
+ {
+ "goal": "一句话目标",
+ "steps": [
+ {
+ "id": "s1",
+ "skill": "skill_id",
+ "tool": "tool-name",
+ "intent": "这一步要做什么",
+ "depends_on": []
+ }
+ ],
+ "needs_clarification": null
+ }
+
+ 严格遵守以下要求:
+ 1. 只输出 JSON object,不要输出 Markdown、代码块、解释或多余文字。
+ 2. `skill` 必须是上面给出的 skill id 之一,`tool` 必须是该 skill 允许的工具之一,使用完整工具名。
+ 3. step 最多 4 步,按执行顺序排列。每个已选 skill 至少规划一步。
+ 4. `depends_on` 里只能引用前面步骤的 id。如果后续步骤需要前面步骤的结果,必须填写依赖。
+ 5. 如果不需要任何技能或工具,返回 `steps: []`。
+ 6. 如果后续步骤需要的信息可以通过前置步骤获得,或者已经出现在"最近已知的工具结果摘要"里,仍然要先把这些步骤规划出来,不要提前提问。
+ 7. 只要 `steps` 里还能放入至少一个可执行步骤,`needs_clarification` 就必须是 null。
+ 8. 只有在没有任何可行步骤可以获得关键缺失信息时,才返回:
+ {"goal":"", "steps": [], "needs_clarification": "请说明具体需要什么"}
+
+ <|turn>model
+
+ """
+ }
+
+ static func buildPlannedToolArgumentsPrompt(
+ originalPrompt: String,
+ userQuestion: String,
+ stepIntent: String,
+ toolName: String,
+ toolParameters: String,
+ completedStepSummary: String = "",
+ currentImageCount: Int = 0
+ ) -> String {
+ let systemBlock = extractSystemBlock(from: originalPrompt)
+ let systemInstructions = injectIntoSystemBlock(
+ systemBlock,
+ extraInstructions: """
+ 对于当前这一个用户问题,你现在只负责为工具提取参数。
+ 不要调用 `load_skill`,不要输出 ``,不要直接回答用户问题。
+ """
+ )
+
+ let completedBlock: String
+ if completedStepSummary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ completedBlock = ""
+ } else {
+ completedBlock = "已完成步骤摘要:\n\(completedStepSummary)"
+ }
+
+ return systemInstructions + extractHistoryBlock(from: originalPrompt) + """
+ <|turn>user
+ 用户问题:
+ \(userQuestion)\(imagePromptSuffix(count: currentImageCount))
+
+ 当前步骤目标:
+ \(stepIntent)
+
+ \(completedBlock)
+
+ 你现在只负责为工具 `\(toolName)` 提取 arguments。
+ 工具参数说明:
+ \(toolParameters)
+
+ 严格遵守以下要求:
+ 1. 不要调用工具,不要输出 ``。
+ 2. 只输出一个 JSON object,内容就是 arguments 本身。
+ 3. 不要输出 Markdown、代码块、解释、字段草稿或多余文字。
+ 4. 可选字段如果没有,就直接省略。
+ 5. 如果上面的已完成步骤里已经包含当前工具需要的信息,可以直接引用那些结果来补齐参数。
+ 6. 时间字段必须转换成 ISO 8601,例如 `2026-04-07T20:00:00`。
+ 7. 如果缺少必填参数,只输出一个 JSON object: {"_needs_clarification": "..."}。
+ "..." 部分必须用一句完整中文,直接陈述当前步骤缺哪个具体参数,
+ 不能含尖括号、不能含占位符、不能复制本规则的任何字面文本。
+
+ <|turn>model
+
+ """
+ }
+
+ static func buildMultiToolAnswerPrompt(
+ originalPrompt: String,
+ toolResults: [(toolName: String, result: String)],
+ userQuestion: String,
+ currentImageCount: Int = 0
+ ) -> String {
+ let systemBlock = extractSystemBlock(from: originalPrompt)
+ var resultsBlock = ""
+ for (toolName, result) in toolResults {
+ resultsBlock += "工具 \(toolName) 的执行结果:\(result)\n"
+ }
+
+ return systemBlock + extractHistoryBlock(from: originalPrompt) + """
+ <|turn>user
+ 用户原始问题:
+ \(userQuestion)\(imagePromptSuffix(count: currentImageCount))
+
+ 所有工具已执行完成:
+ \(resultsBlock)
+
+ 请基于以上所有结果回答用户:
+ - 如果以上结果已经能回答用户问题,直接给出最终回答,不要重复调用已经成功的工具。
+ - 如果还需要继续调用新的工具来补全答案,可以输出一个或多个 `...`。
+ - 不要反问,不要提到工具名、Skill、status、result、arguments 等字段。
+ - 不要输出 Markdown 代码块,也不要输出 JSON、键名、模板或中间步骤。
+ - 不能输出空白。
<|turn>model
diff --git a/PhoneClaw.xcodeproj/project.pbxproj b/PhoneClaw.xcodeproj/project.pbxproj
index 370fbd5..1c75b4d 100644
--- a/PhoneClaw.xcodeproj/project.pbxproj
+++ b/PhoneClaw.xcodeproj/project.pbxproj
@@ -11,13 +11,30 @@
2F4A6C8E0A1B2C3D4E5F6071 /* AudioCaptureService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4A6C8E0A1B2C3D4E5F6070 /* AudioCaptureService.swift */; };
45B7FEC6C4D055F44A87BFA9 /* PhoneClawApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DD9808FE93325C3CE4CFA00 /* PhoneClawApp.swift */; };
5084E1CD2E831BC05F5E4374 /* PromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E7C06DA449D1395217A42D5 /* PromptBuilder.swift */; };
+ 6A7B8C9D0E1F203142536475 /* gemma-4-e4b-it-4bit in Resources */ = {isa = PBXBuildFile; fileRef = D5E6F7081A2B3C4D5E6F7086 /* gemma-4-e4b-it-4bit */; };
9412304167DACB86E5D60D49 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5ED928BF14864713A9A9D16 /* ContentView.swift */; };
97E96A2FBFFAF609F276A896 /* ConfigurationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62C3CEFC7AE63873D751E75 /* ConfigurationsView.swift */; };
+ A31476EA2F856FD700318978 /* ChatAttachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31476E82F856FD700318978 /* ChatAttachments.swift */; };
+ A31476EB2F856FD700318978 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31476E92F856FD700318978 /* ChatMessage.swift */; };
+ A31476F62F85741500318978 /* Planner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31476F02F85741500318978 /* Planner.swift */; };
+ A31476F82F85741500318978 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31476F12F85741500318978 /* Router.swift */; };
+ A31476F92F85741500318978 /* SessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31476F22F85741500318978 /* SessionStore.swift */; };
+ A31476FA2F85741500318978 /* ToolChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31476F42F85741500318978 /* ToolChain.swift */; };
+ A31476FB2F85741500318978 /* OutputCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31476EF2F85741500318978 /* OutputCleaner.swift */; };
+ A31476FC2F85741500318978 /* ToolCallParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31476F32F85741500318978 /* ToolCallParser.swift */; };
AA1000000000000000000001 /* LLMEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000001 /* LLMEngine.swift */; };
AA1000000000000000000002 /* MLXLocalLLMService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000002 /* MLXLocalLLMService.swift */; };
AA1000000000000000000003 /* Gemma4Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000003 /* Gemma4Config.swift */; };
AA1000000000000000000004 /* Gemma4LanguageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000004 /* Gemma4LanguageModel.swift */; };
AA1000000000000000000005 /* Gemma4Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000005 /* Gemma4Model.swift */; };
+ AA1000000000000000000100 /* BundledModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000100 /* BundledModel.swift */; };
+ AA1000000000000000000101 /* RuntimeBudgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000101 /* RuntimeBudgets.swift */; };
+ AA1000000000000000000103 /* ModelInstallState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000103 /* ModelInstallState.swift */; };
+ AA1000000000000000000104 /* ModelPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000104 /* ModelPaths.swift */; };
+ AA1000000000000000000105 /* MemoryStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000105 /* MemoryStats.swift */; };
+ AA1000000000000000000106 /* ModelDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000106 /* ModelDownloader.swift */; };
+ AA1000000000000000000107 /* ModelInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000107 /* ModelInstaller.swift */; };
+ AA1000000000000000000108 /* GPULifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000108 /* GPULifecycle.swift */; };
AA1000000000000000000006 /* Gemma4Registration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000006 /* Gemma4Registration.swift */; };
AA1000000000000000000007 /* Gemma4RoPE.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000007 /* Gemma4RoPE.swift */; };
AA1000000000000000000008 /* Gemma4TextModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000008 /* Gemma4TextModel.swift */; };
@@ -37,10 +54,21 @@
B8A84E4D0DDDE011B5929DA2 /* AgentEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A47114A10132E25E220F8 /* AgentEngine.swift */; };
BD75E7E5620A8FF0A868E9F0 /* libPods-PhoneClaw.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F4ACB3F4531FF61E0AADA07 /* libPods-PhoneClaw.a */; };
C1A2B3D4E5F607182930A1B2 /* SkillLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3D4E5F607182930A1B3 /* SkillLoader.swift */; };
- C2B3C4D5E6F718293041B2C3 /* ToolRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B3C4D5E6F718293041B2C4 /* ToolRegistry.swift */; };
+ C1A2B3D4E5F607182930A1C2 /* SkillRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3D4E5F607182930A1C3 /* SkillRegistry.swift */; };
+ D3A1B2C3D4E5F60718290001 /* ToolRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1B2C3D4E5F60718290101 /* ToolRegistry.swift */; };
+ D3A1B2C3D4E5F60718290002 /* AppPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1B2C3D4E5F60718290102 /* AppPermissions.swift */; };
+ D3A1B2C3D4E5F60718290003 /* Stores.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1B2C3D4E5F60718290103 /* Stores.swift */; };
+ D3A1B2C3D4E5F60718290004 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1B2C3D4E5F60718290104 /* Helpers.swift */; };
+ D3A1B2C3D4E5F60718290005 /* Clipboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1B2C3D4E5F60718290105 /* Clipboard.swift */; };
+ D3A1B2C3D4E5F60718290006 /* Device.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1B2C3D4E5F60718290106 /* Device.swift */; };
+ D3A1B2C3D4E5F60718290007 /* Text.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1B2C3D4E5F60718290107 /* Text.swift */; };
+ D3A1B2C3D4E5F60718290008 /* Calendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1B2C3D4E5F60718290108 /* Calendar.swift */; };
+ D3A1B2C3D4E5F60718290009 /* Reminders.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1B2C3D4E5F60718290109 /* Reminders.swift */; };
+ D3A1B2C3D4E5F60718290010 /* Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1B2C3D4E5F60718290110 /* Contacts.swift */; };
C3C4D5E6F7081A2B3C52D3E4 /* ChatModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C4D5E6F7081A2B3C52D3E5 /* ChatModels.swift */; };
C4D5E6F70819A2B3C463E4F5 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D5E6F70819A2B3C463E4F6 /* Theme.swift */; };
ICONASSETS0000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ICONASSETS0000000000001 /* Assets.xcassets */; };
+ B1A2C3D4E5F607182930B100 /* Library in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D4E5F607182930B101 /* Library */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -54,11 +82,27 @@
52F30C6348B834E5D157205F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
571908036E6DCED1D58C1E9E /* Skills.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Skills.swift; sourceTree = ""; };
A1AB7F250BEEFF938318AA75 /* Pods-PhoneClaw.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PhoneClaw.debug.xcconfig"; path = "Target Support Files/Pods-PhoneClaw/Pods-PhoneClaw.debug.xcconfig"; sourceTree = ""; };
+ A31476E82F856FD700318978 /* ChatAttachments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAttachments.swift; sourceTree = ""; };
+ A31476E92F856FD700318978 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; };
+ A31476EF2F85741500318978 /* OutputCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputCleaner.swift; sourceTree = ""; };
+ A31476F02F85741500318978 /* Planner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Planner.swift; sourceTree = ""; };
+ A31476F12F85741500318978 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; };
+ A31476F22F85741500318978 /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = ""; };
+ A31476F32F85741500318978 /* ToolCallParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolCallParser.swift; sourceTree = ""; };
+ A31476F42F85741500318978 /* ToolChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolChain.swift; sourceTree = ""; };
AA2000000000000000000001 /* LLMEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMEngine.swift; sourceTree = ""; };
AA2000000000000000000002 /* MLXLocalLLMService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLXLocalLLMService.swift; sourceTree = ""; };
AA2000000000000000000003 /* Gemma4Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gemma4Config.swift; sourceTree = ""; };
AA2000000000000000000004 /* Gemma4LanguageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gemma4LanguageModel.swift; sourceTree = ""; };
AA2000000000000000000005 /* Gemma4Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gemma4Model.swift; sourceTree = ""; };
+ AA2000000000000000000100 /* BundledModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledModel.swift; sourceTree = ""; };
+ AA2000000000000000000101 /* RuntimeBudgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeBudgets.swift; sourceTree = ""; };
+ AA2000000000000000000103 /* ModelInstallState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelInstallState.swift; sourceTree = ""; };
+ AA2000000000000000000104 /* ModelPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelPaths.swift; sourceTree = ""; };
+ AA2000000000000000000105 /* MemoryStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryStats.swift; sourceTree = ""; };
+ AA2000000000000000000106 /* ModelDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelDownloader.swift; sourceTree = ""; };
+ AA2000000000000000000107 /* ModelInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelInstaller.swift; sourceTree = ""; };
+ AA2000000000000000000108 /* GPULifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPULifecycle.swift; sourceTree = ""; };
AA2000000000000000000006 /* Gemma4Registration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gemma4Registration.swift; sourceTree = ""; };
AA2000000000000000000007 /* Gemma4RoPE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gemma4RoPE.swift; sourceTree = ""; };
AA2000000000000000000008 /* Gemma4TextModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gemma4TextModel.swift; sourceTree = ""; };
@@ -68,7 +112,17 @@
AA2000000000000000000020 /* Gemma4AudioFeatureExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gemma4AudioFeatureExtractor.swift; sourceTree = ""; };
AA2000000000000000000021 /* Gemma4AudioModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gemma4AudioModel.swift; sourceTree = ""; };
C1A2B3D4E5F607182930A1B3 /* SkillLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillLoader.swift; sourceTree = ""; };
- C2B3C4D5E6F718293041B2C4 /* ToolRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolRegistry.swift; sourceTree = ""; };
+ C1A2B3D4E5F607182930A1C3 /* SkillRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillRegistry.swift; sourceTree = ""; };
+ D3A1B2C3D4E5F60718290101 /* ToolRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolRegistry.swift; sourceTree = ""; };
+ D3A1B2C3D4E5F60718290102 /* AppPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissions.swift; sourceTree = ""; };
+ D3A1B2C3D4E5F60718290103 /* Stores.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stores.swift; sourceTree = ""; };
+ D3A1B2C3D4E5F60718290104 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; };
+ D3A1B2C3D4E5F60718290105 /* Clipboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clipboard.swift; sourceTree = ""; };
+ D3A1B2C3D4E5F60718290106 /* Device.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Device.swift; sourceTree = ""; };
+ D3A1B2C3D4E5F60718290107 /* Text.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Text.swift; sourceTree = ""; };
+ D3A1B2C3D4E5F60718290108 /* Calendar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Calendar.swift; sourceTree = ""; };
+ D3A1B2C3D4E5F60718290109 /* Reminders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reminders.swift; sourceTree = ""; };
+ D3A1B2C3D4E5F60718290110 /* Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contacts.swift; sourceTree = ""; };
C3C4D5E6F7081A2B3C52D3E5 /* ChatModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModels.swift; sourceTree = ""; };
C4D5E6F70819A2B3C463E4F6 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; };
D269F8BFCAB68D82440F704B /* SkillsManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillsManagerView.swift; sourceTree = ""; };
@@ -78,6 +132,7 @@
E62C3CEFC7AE63873D751E75 /* ConfigurationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationsView.swift; sourceTree = ""; };
F5ED928BF14864713A9A9D16 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
ICONASSETS0000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ B1A2C3D4E5F607182930B101 /* Library */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Library; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -131,31 +186,105 @@
98EFD7CCA7E78715F07BABE3 /* Skills */ = {
isa = PBXGroup;
children = (
+ B1A2C3D4E5F607182930B101 /* Library */,
571908036E6DCED1D58C1E9E /* Skills.swift */,
C1A2B3D4E5F607182930A1B3 /* SkillLoader.swift */,
- C2B3C4D5E6F718293041B2C4 /* ToolRegistry.swift */,
+ C1A2B3D4E5F607182930A1C3 /* SkillRegistry.swift */,
);
path = Skills;
sourceTree = "";
};
+ D3A1B2C3D4E5F60718290200 /* Tools */ = {
+ isa = PBXGroup;
+ children = (
+ D3A1B2C3D4E5F60718290101 /* ToolRegistry.swift */,
+ D3A1B2C3D4E5F60718290102 /* AppPermissions.swift */,
+ D3A1B2C3D4E5F60718290103 /* Stores.swift */,
+ D3A1B2C3D4E5F60718290104 /* Helpers.swift */,
+ D3A1B2C3D4E5F60718290300 /* Handlers */,
+ );
+ path = Tools;
+ sourceTree = "";
+ };
+ D3A1B2C3D4E5F60718290300 /* Handlers */ = {
+ isa = PBXGroup;
+ children = (
+ D3A1B2C3D4E5F60718290105 /* Clipboard.swift */,
+ D3A1B2C3D4E5F60718290106 /* Device.swift */,
+ D3A1B2C3D4E5F60718290107 /* Text.swift */,
+ D3A1B2C3D4E5F60718290108 /* Calendar.swift */,
+ D3A1B2C3D4E5F60718290109 /* Reminders.swift */,
+ D3A1B2C3D4E5F60718290110 /* Contacts.swift */,
+ );
+ path = Handlers;
+ sourceTree = "";
+ };
+ A31476ED2F85741500318978 /* Engine */ = {
+ isa = PBXGroup;
+ children = (
+ A31476EF2F85741500318978 /* OutputCleaner.swift */,
+ A31476F02F85741500318978 /* Planner.swift */,
+ A31476F12F85741500318978 /* Router.swift */,
+ A31476F22F85741500318978 /* SessionStore.swift */,
+ A31476F32F85741500318978 /* ToolCallParser.swift */,
+ A31476F42F85741500318978 /* ToolChain.swift */,
+ A31476E82F856FD700318978 /* ChatAttachments.swift */,
+ A31476E92F856FD700318978 /* ChatMessage.swift */,
+ );
+ path = Engine;
+ sourceTree = "";
+ };
AA3000000000000000000001 /* MLX */ = {
isa = PBXGroup;
children = (
- AA2000000000000000000003 /* Gemma4Config.swift */,
- AA2000000000000000000004 /* Gemma4LanguageModel.swift */,
- AA2000000000000000000020 /* Gemma4AudioFeatureExtractor.swift */,
- AA2000000000000000000021 /* Gemma4AudioModel.swift */,
- AA2000000000000000000005 /* Gemma4Model.swift */,
- AA2000000000000000000006 /* Gemma4Registration.swift */,
- AA2000000000000000000007 /* Gemma4RoPE.swift */,
- AA2000000000000000000008 /* Gemma4TextModel.swift */,
- AA2000000000000000000018 /* Gemma4VisionModel.swift */,
- AA2000000000000000000019 /* Gemma4Processor.swift */,
- AA2000000000000000000009 /* MLXTokenizersLoader.swift */,
+ AA3000000000000000000010 /* Core */,
+ AA3000000000000000000011 /* Installation */,
+ AA3000000000000000000012 /* Gemma4 */,
+ AA2000000000000000000002 /* MLXLocalLLMService.swift */,
);
path = MLX;
sourceTree = "";
};
+ AA3000000000000000000010 /* Core */ = {
+ isa = PBXGroup;
+ children = (
+ AA2000000000000000000100 /* BundledModel.swift */,
+ AA2000000000000000000101 /* RuntimeBudgets.swift */,
+ AA2000000000000000000105 /* MemoryStats.swift */,
+ AA2000000000000000000108 /* GPULifecycle.swift */,
+ AA2000000000000000000009 /* MLXTokenizersLoader.swift */,
+ );
+ path = Core;
+ sourceTree = "";
+ };
+ AA3000000000000000000011 /* Installation */ = {
+ isa = PBXGroup;
+ children = (
+ AA2000000000000000000104 /* ModelPaths.swift */,
+ AA2000000000000000000103 /* ModelInstallState.swift */,
+ AA2000000000000000000107 /* ModelInstaller.swift */,
+ AA2000000000000000000106 /* ModelDownloader.swift */,
+ );
+ path = Installation;
+ sourceTree = "";
+ };
+ AA3000000000000000000012 /* Gemma4 */ = {
+ isa = PBXGroup;
+ children = (
+ AA2000000000000000000003 /* Gemma4Config.swift */,
+ AA2000000000000000000005 /* Gemma4Model.swift */,
+ AA2000000000000000000004 /* Gemma4LanguageModel.swift */,
+ AA2000000000000000000008 /* Gemma4TextModel.swift */,
+ AA2000000000000000000018 /* Gemma4VisionModel.swift */,
+ AA2000000000000000000021 /* Gemma4AudioModel.swift */,
+ AA2000000000000000000020 /* Gemma4AudioFeatureExtractor.swift */,
+ AA2000000000000000000007 /* Gemma4RoPE.swift */,
+ AA2000000000000000000019 /* Gemma4Processor.swift */,
+ AA2000000000000000000006 /* Gemma4Registration.swift */,
+ );
+ path = Gemma4;
+ sourceTree = "";
+ };
AC5E0AA2711EA01E891DE3A7 /* Products */ = {
isa = PBXGroup;
children = (
@@ -169,7 +298,6 @@
children = (
AA2000000000000000000001 /* LLMEngine.swift */,
AA3000000000000000000001 /* MLX */,
- AA2000000000000000000002 /* MLXLocalLLMService.swift */,
2E7C06DA449D1395217A42D5 /* PromptBuilder.swift */,
);
path = LLM;
@@ -185,6 +313,7 @@
6FA4C5AFA3123551D8F8F2F2 /* App */,
AE5FF3067EFF33D507435528 /* LLM */,
98EFD7CCA7E78715F07BABE3 /* Skills */,
+ D3A1B2C3D4E5F60718290200 /* Tools */,
EF4D07A808616D6F13771DBE /* UI */,
);
name = PhoneClaw;
@@ -193,6 +322,7 @@
E0660A5AE3C683B7B20600EE /* Agent */ = {
isa = PBXGroup;
children = (
+ A31476ED2F85741500318978 /* Engine */,
2F4A6C8E0A1B2C3D4E5F6070 /* AudioCaptureService.swift */,
E18A47114A10132E25E220F8 /* AgentEngine.swift */,
);
@@ -287,7 +417,9 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 6A7B8C9D0E1F203142536475 /* gemma-4-e4b-it-4bit in Resources */,
ICONASSETS0000000000002 /* Assets.xcassets in Resources */,
+ B1A2C3D4E5F607182930B100 /* Library in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -332,7 +464,21 @@
AA1000000000000000000004 /* Gemma4LanguageModel.swift in Sources */,
AA1000000000000000000020 /* Gemma4AudioFeatureExtractor.swift in Sources */,
AA1000000000000000000021 /* Gemma4AudioModel.swift in Sources */,
+ A31476F62F85741500318978 /* Planner.swift in Sources */,
+ A31476F82F85741500318978 /* Router.swift in Sources */,
+ A31476F92F85741500318978 /* SessionStore.swift in Sources */,
+ A31476FA2F85741500318978 /* ToolChain.swift in Sources */,
+ A31476FB2F85741500318978 /* OutputCleaner.swift in Sources */,
+ A31476FC2F85741500318978 /* ToolCallParser.swift in Sources */,
AA1000000000000000000005 /* Gemma4Model.swift in Sources */,
+ AA1000000000000000000100 /* BundledModel.swift in Sources */,
+ AA1000000000000000000101 /* RuntimeBudgets.swift in Sources */,
+ AA1000000000000000000103 /* ModelInstallState.swift in Sources */,
+ AA1000000000000000000104 /* ModelPaths.swift in Sources */,
+ AA1000000000000000000105 /* MemoryStats.swift in Sources */,
+ AA1000000000000000000106 /* ModelDownloader.swift in Sources */,
+ AA1000000000000000000107 /* ModelInstaller.swift in Sources */,
+ AA1000000000000000000108 /* GPULifecycle.swift in Sources */,
AA1000000000000000000006 /* Gemma4Registration.swift in Sources */,
AA1000000000000000000018 /* Gemma4VisionModel.swift in Sources */,
AA1000000000000000000019 /* Gemma4Processor.swift in Sources */,
@@ -344,10 +490,22 @@
45B7FEC6C4D055F44A87BFA9 /* PhoneClawApp.swift in Sources */,
5084E1CD2E831BC05F5E4374 /* PromptBuilder.swift in Sources */,
C1A2B3D4E5F607182930A1B2 /* SkillLoader.swift in Sources */,
+ C1A2B3D4E5F607182930A1C2 /* SkillRegistry.swift in Sources */,
20B8C55E032BA07B3E640E80 /* Skills.swift in Sources */,
B881A2E7F9483D5ED2AC206B /* SkillsManagerView.swift in Sources */,
+ A31476EA2F856FD700318978 /* ChatAttachments.swift in Sources */,
+ A31476EB2F856FD700318978 /* ChatMessage.swift in Sources */,
C4D5E6F70819A2B3C463E4F5 /* Theme.swift in Sources */,
- C2B3C4D5E6F718293041B2C3 /* ToolRegistry.swift in Sources */,
+ D3A1B2C3D4E5F60718290001 /* ToolRegistry.swift in Sources */,
+ D3A1B2C3D4E5F60718290002 /* AppPermissions.swift in Sources */,
+ D3A1B2C3D4E5F60718290003 /* Stores.swift in Sources */,
+ D3A1B2C3D4E5F60718290004 /* Helpers.swift in Sources */,
+ D3A1B2C3D4E5F60718290005 /* Clipboard.swift in Sources */,
+ D3A1B2C3D4E5F60718290006 /* Device.swift in Sources */,
+ D3A1B2C3D4E5F60718290007 /* Text.swift in Sources */,
+ D3A1B2C3D4E5F60718290008 /* Calendar.swift in Sources */,
+ D3A1B2C3D4E5F60718290009 /* Reminders.swift in Sources */,
+ D3A1B2C3D4E5F60718290010 /* Contacts.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -482,6 +640,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = PhoneClaw.entitlements;
CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = ZP4QNJ35TH;
INFOPLIST_FILE = Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -501,6 +660,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = PhoneClaw.entitlements;
CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = ZP4QNJ35TH;
INFOPLIST_FILE = Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
diff --git a/PhoneClaw.xcodeproj/xcshareddata/xcschemes/PhoneClaw.xcscheme b/PhoneClaw.xcodeproj/xcshareddata/xcschemes/PhoneClaw.xcscheme
new file mode 100644
index 0000000..88e527a
--- /dev/null
+++ b/PhoneClaw.xcodeproj/xcshareddata/xcschemes/PhoneClaw.xcscheme
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Skills/Library/calendar/SKILL.md b/Skills/Library/calendar/SKILL.md
new file mode 100644
index 0000000..8765149
--- /dev/null
+++ b/Skills/Library/calendar/SKILL.md
@@ -0,0 +1,67 @@
+---
+name: Calendar
+name-zh: 日历
+description: '创建新的日历事项 (会议/约会/日程)。'
+version: "1.0.0"
+icon: calendar
+disabled: false
+type: device
+
+triggers:
+ - 日历
+ - 日程
+ - 会议
+ - 约会
+ - 安排
+
+allowed-tools:
+ - calendar-create-event
+
+examples:
+ - query: "帮我创建明天下午两点的产品评审会议"
+ scenario: "新建日历事项"
+---
+
+# 日历事项创建
+
+严格遵循下面的参数规则, 不要自由发挥, 不要反问多余问题。
+
+## 参数
+
+**硬参** (必填, 缺失才允许追问):
+- `start`: ISO 8601 开始时间, 例如 `2026-04-07T14:00:00`
+
+**软参** (用户没说就省略字段, 永远不追问):
+- `title`: 事项标题
+- `end`: 结束时间
+- `location`: 地点
+- `notes`: 备注
+
+## start 提取规则
+
+用户话语里**只要有任何时间线索就视为 start 已提供**:
+- 相对时间: "明天下午两点" / "今晚八点" / "一小时后" / "后天中午"
+- 绝对时间: "5月3日 15:00" / "4月10日晚上"
+
+结合 system prompt 的**当前时间锚点**算出 ISO 8601 字符串。**禁止**对已经给了相对时间的用户反问 "哪一天"。
+
+只有用户完全没给时间 (如"安排个会议") 才允许输出:
+```
+{"_needs_clarification": "请告诉我具体时间"}
+```
+
+## title 处理
+
+- 用户话语里有名词短语 (如"产品评审会议") → 直接当 title
+- 只有动作 (如"安排个会") → title 字段省略, 工具会用默认
+- **永远禁止**输出 "请告诉我标题" / "标题是什么" 这类反问
+
+## 行为
+
+拿到 start 立刻输出工具调用 JSON, 不解释、不反问、不追问 end/location/notes。
+
+## 调用格式
+
+
+{"name": "calendar-create-event", "arguments": {"title": "产品评审会议", "start": "2026-04-07T14:00:00"}}
+
diff --git a/Skills/Library/clipboard/SKILL.md b/Skills/Library/clipboard/SKILL.md
new file mode 100644
index 0000000..8baba7b
--- /dev/null
+++ b/Skills/Library/clipboard/SKILL.md
@@ -0,0 +1,46 @@
+---
+name: Clipboard
+name-zh: 剪贴板
+description: '读写系统剪贴板内容。当用户需要读取、复制或操作剪贴板时使用。'
+version: "1.0.0"
+icon: doc.on.clipboard
+disabled: false
+type: device
+
+triggers:
+ - 剪贴板
+ - 粘贴
+ - 复制
+ - clipboard
+
+allowed-tools:
+ - clipboard-read
+ - clipboard-write
+
+examples:
+ - query: "读取我的剪贴板内容"
+ scenario: "读取剪贴板"
+ - query: "把这段文字复制到剪贴板"
+ scenario: "写入剪贴板"
+---
+
+# 剪贴板操作
+
+你负责帮助用户读写系统剪贴板。
+
+## 可用工具
+
+- **clipboard-read**: 读取剪贴板当前内容(无参数)
+- **clipboard-write**: 将文本写入剪贴板(参数: text — 要复制的文本)
+
+## 执行流程
+
+1. 用户要求读取 → 调用 `clipboard-read`
+2. 用户要求复制/写入 → 调用 `clipboard-write`,传入 text 参数
+3. 根据工具返回结果,简洁回答用户
+
+## 调用格式
+
+
+{"name": "工具名", "arguments": {}}
+
diff --git a/Skills/Library/contacts/SKILL.md b/Skills/Library/contacts/SKILL.md
new file mode 100644
index 0000000..6107ed8
--- /dev/null
+++ b/Skills/Library/contacts/SKILL.md
@@ -0,0 +1,124 @@
+---
+name: Contacts
+name-zh: 通讯录
+description: '查询、创建、更新或删除联系人。当用户要查电话、看联系方式、存号码、补充联系人信息或删除联系人时使用。'
+version: "1.1.0"
+icon: person.crop.circle
+disabled: false
+type: device
+
+triggers:
+ - 联系人
+ - 通讯录
+ - 查电话
+ - 联系电话
+ - 存号码
+ - 联系方式
+ - 删除联系人
+
+allowed-tools:
+ - contacts-search
+ - contacts-upsert
+ - contacts-delete
+
+examples:
+ - 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. 如果用户在查询电话、邮箱、联系方式,优先调用 `contacts-search`
+2. 如果用户在删除、移除、删掉联系人,调用 `contacts-delete`
+3. 如果用户在保存、添加或更新联系人,调用 `contacts-upsert`
+4. 查询或删除时优先提取 `name`,提取不到再用 `query`
+5. 保存或更新时提取姓名、手机号、公司、邮箱、备注
+6. 如果缺少保存联系人所需的 `name`,先简短追问
+7. 工具成功后,直接用中文给出简洁结果
+
+## 多轮澄清处理
+
+### 找到多个匹配时
+
+调用 `contacts-search` 或 `contacts-delete` 后,如果工具结果显示有多个候选(matches > 1),不要直接报错或乱选一个。按以下格式问用户:
+
+> 找到多个 [name]:
+> (1) [phone1] · [extra info]
+> (2) [phone2] · [extra info]
+>
+> 要操作哪一个?回复编号、电话号码后几位,或"全部"。
+
+把这些候选信息**保留**在你的回答里,下一轮用户回应时你需要参考。
+
+### 用户回答澄清后(关键)
+
+如果上一轮你刚问过用户"要操作哪一个",**当前用户消息就是答案**。不要再问一次,按答案语义解析后**重新调用同一个工具**:
+
+| 用户说什么 | 含义 | 怎么调 |
+|---|---|---|
+| 完整电话 `15212345678` | 精确指定 | 用 `phone` 参数加完整号码 |
+| 尾号 `5678` / "尾号 5678" | 模糊定位 | 用 `query` 参数加尾号 |
+| 编号 `1` / `(1)` / "第一个" | 选候选列表第 N 个 | 取上一轮列出的第 N 个的电话作为 `phone` |
+| "全部" / "都删" / "两个都" | 操作所有候选 | 对每个候选**依次**调用工具,每次用 `phone` 精确定位一个 |
+| 名字 + 公司 / 备注 | 用区分性信息 | 加 `name` + `notes` 等参数 |
+
+调用例(用户回答 "152123458"):
+
+{"name": "contacts-delete", "arguments": {"name": "张总", "phone": "152123458"}}
+
+
+### 用户取消时
+
+如果用户在多轮澄清过程中表达了**放弃意图**——例如说"算了"、"不删了"、"取消"、"停"、"nevermind",或任何自然语言里表示不想继续的意思——**直接给一句简短确认**(如"好的,已取消"),**不要 emit 任何 tool_call**。
+
+判断"是否在表达放弃"由你自己理解上下文,不要依赖任何固定关键词列表。模型有自然语言理解能力,请用它。
+
+### 不要捏造执行结果
+
+如果你**没有真正调用 tool**,**绝对不要**说"已经删除"、"已添加"、"已更新"。
+- 要么 emit 一个真实的 ``
+- 要么如实告诉用户你需要更多信息或操作已取消
+- **不允许**只输出"已完成"这种文本而不调工具
+
+## 调用格式
+
+
+{"name": "contacts-search", "arguments": {"name": "张晓霞"}}
+
+
+
+{"name": "contacts-upsert", "arguments": {"name": "王总", "phone": "13812345678", "company": "字节"}}
+
+
+
+{"name": "contacts-delete", "arguments": {"name": "王总"}}
+
diff --git a/Skills/Library/device/SKILL.md b/Skills/Library/device/SKILL.md
new file mode 100644
index 0000000..edd7ca5
--- /dev/null
+++ b/Skills/Library/device/SKILL.md
@@ -0,0 +1,61 @@
+---
+name: Device
+name-zh: 设备
+description: '查询当前设备的名称、机型、系统版本、内存、处理器数量等系统信息 (基于 iOS 公开 API)。'
+version: "1.0.0"
+icon: desktopcomputer
+disabled: false
+type: device
+
+triggers:
+ - 设备
+ - 系统信息
+ - 当前设备
+ - 本机
+ - 系统版本
+ - 内存
+ - 处理器
+
+allowed-tools:
+ - device-info
+ - device-name
+ - device-model
+ - device-system-version
+ - device-memory
+ - device-processor-count
+ - device-identifier-for-vendor
+
+examples:
+ - query: "这台手机的设备信息是什么"
+ scenario: "整体汇总"
+ - query: "系统版本是多少"
+ scenario: "查单项"
+ - query: "内存多大"
+ scenario: "查单项"
+---
+
+# 设备信息查询
+
+## 工具选择
+
+- 用户问"整体配置 / 全部信息 / 这台手机什么样" → `device-info`
+- 用户问单个属性 → 用最小的专用工具:
+ - 名字 → `device-name`
+ - 机型 → `device-model`
+ - 系统版本 → `device-system-version`
+ - 内存 → `device-memory`
+ - CPU 核数 → `device-processor-count`
+ - 厂商标识 → `device-identifier-for-vendor`
+
+## 行为约束
+
+1. 必须用户**明确**问当前设备/本机/手机的属性才调用。"配置""信息"这种含糊词不触发。
+2. iOS 公开 API 只能返回通用机型 (如 `iPhone`),**不要**自己映射成 "iPhone 17 Pro Max" 这种营销名。如果用户问"什么型号", 如实说明限制。
+3. 工具返回 JSON 后, 转成自然中文描述, 不要照搬原始字段名。
+4. 拿到结果直接答, 不要反问 "你指的是什么配置"。
+
+## 调用格式
+
+
+{"name": "device-info", "arguments": {}}
+
diff --git a/Skills/Library/reminders/SKILL.md b/Skills/Library/reminders/SKILL.md
new file mode 100644
index 0000000..61a05c9
--- /dev/null
+++ b/Skills/Library/reminders/SKILL.md
@@ -0,0 +1,47 @@
+---
+name: Reminders
+name-zh: 提醒事项
+description: '创建新的提醒事项。当用户需要记得做某事、设置待办或提醒时使用。'
+version: "1.0.0"
+icon: bell
+disabled: false
+type: device
+
+triggers:
+ - 提醒
+ - 待办
+ - 记得
+ - 提示
+
+allowed-tools:
+ - reminders-create
+
+examples:
+ - query: "提醒我今晚八点发文件"
+ scenario: "新建提醒事项"
+---
+
+# 提醒事项创建
+
+你负责帮助用户创建新的提醒事项。
+
+## 可用工具
+
+- **reminders-create**: 创建提醒事项
+ - `title`: 必填,提醒标题
+ - `due`: 可选,ISO 8601 提醒时间,例如 `2026-04-07T20:00:00`
+ - `notes`: 可选,备注
+
+## 执行流程
+
+1. 只有当用户明确要设置提醒或待办时才调用工具
+2. 由你提取标题、时间、备注
+3. 如果有时间,必须转换成 ISO 8601 字符串
+4. 如果缺少 `title`,先简短追问
+5. 工具成功后,直接告诉用户提醒已创建
+
+## 调用格式
+
+
+{"name": "reminders-create", "arguments": {"title": "发文件", "due": "2026-04-07T20:00:00"}}
+
diff --git a/Skills/Library/text/SKILL.md b/Skills/Library/text/SKILL.md
new file mode 100644
index 0000000..f568484
--- /dev/null
+++ b/Skills/Library/text/SKILL.md
@@ -0,0 +1,47 @@
+---
+name: Text
+name-zh: 文本
+description: '文本处理工具:哈希计算、翻转等。当用户需要对文本进行处理或转换时使用。'
+version: "1.0.0"
+icon: textformat
+disabled: false
+type: device
+
+triggers:
+ - 哈希
+ - hash
+ - 翻转
+ - 反转
+ - 文本处理
+
+allowed-tools:
+ - calculate-hash
+ - text-reverse
+
+examples:
+ - query: "计算 Hello World 的哈希值"
+ scenario: "哈希计算"
+ - query: "把这段文字翻转过来"
+ scenario: "文本翻转"
+---
+
+# 文本处理
+
+你负责帮助用户进行文本处理操作。
+
+## 可用工具
+
+- **calculate-hash**: 计算文本的哈希值(参数: text — 要计算哈希的文本)
+- **text-reverse**: 翻转文本(参数: text — 要翻转的文本)
+
+## 执行流程
+
+1. 判断用户需要哪种文本操作
+2. 调用对应工具,传入 text 参数
+3. 返回处理结果
+
+## 调用格式
+
+
+{"name": "工具名", "arguments": {"text": "要处理的文本"}}
+
diff --git a/Skills/Library/translate/SKILL.md b/Skills/Library/translate/SKILL.md
new file mode 100644
index 0000000..ae269db
--- /dev/null
+++ b/Skills/Library/translate/SKILL.md
@@ -0,0 +1,60 @@
+---
+name: Translate
+name-zh: 翻译
+description: '专业翻译助手, 支持任意语种互译。'
+version: "1.1.0"
+icon: character.bubble
+disabled: false
+type: content
+
+triggers:
+ - 翻译
+ - 译成
+ - 翻成
+ - translate
+ - translation
+ - 中译英
+ - 英译中
+ - 译为
+
+allowed-tools: []
+
+examples:
+ - query: "把下面这段话翻译成英文: 今天天气真好"
+ scenario: "中译英"
+ - query: "translate to Chinese: The early bird catches the worm"
+ scenario: "英译中"
+ - query: "把刚才那段译成日语"
+ scenario: "指代历史"
+---
+
+# 专业翻译
+
+直接输出译文, 不加前缀、不解释过程、不列备选。
+
+## 识别源文本
+
+1. 用户消息里有引号或冒号后的内容 → 取该段
+2. 用户用了"这段 / 刚才那段 / 上面 / 前面"等指代词 → 取对话历史中最近一条 assistant 消息的正文 (跳过 ⚠️ 警告行和"好的"之类单句确认)
+3. 以上都没有 → 才允许追问 "请提供需要翻译的文本"
+
+## 识别目标语言
+
+- 用户明示 (如 "译成英文") → 按用户要求
+- 用户问 "是什么意思" → 默认译成中文
+- 源文本是中文且未指定 → 默认译成英文
+
+## 翻译准则
+
+**信达雅**: 忠实原意 + 符合目标语言语法习惯 + 匹配原文语域 (正式/口语/文学/技术)。
+
+具体:
+- 成语用等效表达, 不字面直译 ("画蛇添足" → "gild the lily")
+- 中译英要补全省略的主语, 英译中按中文习惯省略
+- 日韩 → 中/英 调整 SOV→SVO 语序
+- 标点用目标语言体系 (中文全角, 英文半角)
+- 专有名词 (人名/地名/品牌) 保留或用公认译法
+
+## 输出
+
+只输出译文文本本身。源文本歧义且需要上下文澄清时, 先给最可能译文, 下一段简短补一句"另一种可能是 ..."。
diff --git a/Skills/SkillLoader.swift b/Skills/SkillLoader.swift
index 97df08f..8aac22b 100644
--- a/Skills/SkillLoader.swift
+++ b/Skills/SkillLoader.swift
@@ -1,12 +1,19 @@
import Foundation
import Yams
-// MARK: - SKILL.md 解析器 + 加载器
+// MARK: - SKILL.md 解析 + Skill Registry
//
-// 参考 Vera 项目的 skill_loader.py,实现 Swift 版。
-// 渐进式加载:
-// 1. 启动时:只加载 YAML frontmatter(元数据)
-// 2. load_skill 时:加载完整 body(指令体)
+// 拆分 (对称 ToolRegistry):
+// - SkillLoader : 无状态, 纯解析 + bundle 读取 (static helpers)
+// - SkillRegistry: 有状态, 持有 [id: SkillDefinition], 提供注册/查询/启用/保存
+//
+// 注册制架构:
+// - Skills 在 app 启动时显式 register, 不再扫描文件系统
+// - Built-in skills 从 Bundle.main/Library//SKILL.md 读取
+// - 用户编辑 -> saveSkill 写 Application Support/skills//SKILL.md
+// 作为 override; 下次启动注册时优先使用 override (读不到才回 bundle)
+// - 未来 download / import / in-app 创建 复用同一个 register API
+// (给出 SkillDefinition 即可, 内容来源无感知)
// MARK: - 数据模型
@@ -15,6 +22,19 @@ struct SkillExample {
let scenario: String
}
+/// Skill 类别。决定 system prompt 给模型的调用规则。
+///
+/// - device : 访问 iPhone 硬件或系统数据 (clipboard/calendar/contacts/...).
+/// 只有当用户明确要求执行设备操作时才 load_skill。
+/// 含糊词不触发, 闲聊不触发。
+/// - content: 对文本做变换 (translate/summarize/rewrite/...).
+/// 只要用户意图是该类操作就立即 load_skill, 包括"翻译这段"
+/// 这种带指代词的请求 (skill body 会指导从历史定位源文本)。
+enum SkillType: String, Sendable {
+ case device
+ case content
+}
+
struct SkillMetadata {
let id: String // 目录名 "clipboard"
let name: String // 默认英文名 / 回退显示名
@@ -23,6 +43,7 @@ struct SkillMetadata {
let version: String
let icon: String
let disabled: Bool
+ let type: SkillType // 类别 (frontmatter `type:` 必填, 缺省视为 .device)
let triggers: [String]
let allowedTools: [String]
let examples: [SkillExample]
@@ -39,9 +60,9 @@ struct SkillMetadata {
struct SkillDefinition: Identifiable {
let id: String
- let filePath: URL
+ let filePath: URL // 内容来源 URL (bundle 或 override)
let metadata: SkillMetadata
- var body: String? // Markdown body(懒加载)
+ var body: String? // Markdown body (register 时已解析填充)
var isEnabled: Bool
/// 完整的 SKILL.md 原始内容
@@ -50,140 +71,68 @@ struct SkillDefinition: Identifiable {
}
}
-// MARK: - Skill Loader
+// MARK: - Skill Loader (无状态: 解析 + bundle 读取)
+//
+// 有状态的注册表见 SkillRegistry.swift。
-class SkillLoader {
- private static let skillAliases: [String: String] = [
- "contacts_delete": "contacts",
- "contacts-delete": "contacts"
- ]
+enum SkillLoader {
- let skillsDirectory: URL
- private var cache: [String: SkillDefinition] = [:]
-
- init() {
- let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
- self.skillsDirectory = appSupport.appendingPathComponent("PhoneClaw/skills", isDirectory: true)
- ensureDefaultSkills()
- }
-
- // MARK: - 公开接口
-
- /// 发现并加载所有 Skill 的元数据
- func discoverSkills() -> [SkillDefinition] {
- cache.removeAll()
- guard let items = try? FileManager.default.contentsOfDirectory(
- at: skillsDirectory,
- includingPropertiesForKeys: [.isDirectoryKey],
- options: .skipsHiddenFiles
- ) else { return [] }
-
- var results: [SkillDefinition] = []
- for item in items.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
- var isDir: ObjCBool = false
- FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir)
- guard isDir.boolValue else { continue }
-
- let skillFile = item.appendingPathComponent("SKILL.md")
- 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)
- }
+ /// 从 Bundle.main/Library//SKILL.md 读取并解析为 SkillDefinition
+ static func loadFromBundle(id: String) -> SkillDefinition? {
+ guard let bundleURL = Bundle.main.url(
+ forResource: "SKILL",
+ withExtension: "md",
+ subdirectory: "Library/\(id)"
+ ) else {
+ print("[SkillLoader] skill '\(id)' 未找到: Bundle.main/Library/\(id)/SKILL.md")
+ return nil
}
- return results
- }
-
- /// 完整加载 Skill(包括 body)— load_skill 时调用
- func loadBody(skillId: String) -> String? {
- let resolvedSkillId = canonicalSkillId(for: skillId)
- if let cached = cache[resolvedSkillId], cached.body != nil {
- return cached.body
+ guard let content = try? String(contentsOf: bundleURL, encoding: .utf8) else {
+ print("[SkillLoader] skill '\(id)' 读取失败")
+ return nil
}
- let skillFile = skillsDirectory
- .appendingPathComponent(resolvedSkillId, isDirectory: true)
- .appendingPathComponent("SKILL.md")
-
- guard let content = try? String(contentsOf: skillFile, encoding: .utf8) else { return nil }
- let body = parseBody(content)
- cache[resolvedSkillId]?.body = body
- return body
+ return parseDefinition(id: id, content: content, filePath: bundleURL)
}
- /// 保存 SKILL.md(编辑后写回)
- func saveSkill(skillId: String, content: String) throws {
- let skillFile = skillsDirectory
- .appendingPathComponent(skillId, isDirectory: true)
- .appendingPathComponent("SKILL.md")
- try content.write(to: skillFile, atomically: true, encoding: .utf8)
- // 清缓存,下次重新解析
- cache.removeValue(forKey: skillId)
+ /// 从任意 URL 读取并解析
+ static func loadFromFile(id: String, url: URL) -> SkillDefinition? {
+ guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }
+ return parseDefinition(id: id, content: content, filePath: url)
}
- /// 重新加载所有(热更新入口)
- func reloadAll() -> [SkillDefinition] {
- return discoverSkills()
- }
-
- /// 根据工具名反查 Skill ID
- func findSkillId(forTool toolName: String) -> String? {
- for (id, def) in cache {
- if def.metadata.allowedTools.contains(toolName) {
- return id
- }
- }
- return nil
- }
-
- /// 获取缓存的 SkillDefinition
- func getDefinition(_ skillId: String) -> SkillDefinition? {
- cache[canonicalSkillId(for: skillId)]
- }
-
- /// 更新启用状态
- func setEnabled(_ skillId: String, enabled: Bool) {
- cache[canonicalSkillId(for: skillId)]?.isEnabled = enabled
- }
-
- func canonicalSkillId(for skillId: String) -> String {
- Self.skillAliases[skillId] ?? skillId
- }
-
- // MARK: - 解析
-
- private func loadDefinition(skillId: String, file: URL) -> SkillDefinition? {
- guard let content = try? String(contentsOf: file, encoding: .utf8) else { return nil }
+ /// 解析 SKILL.md 文本为 SkillDefinition
+ static func parseDefinition(id: String, content: String, filePath: URL) -> SkillDefinition? {
guard let frontmatter = parseFrontmatter(content) else { return nil }
+ let typeRaw = (frontmatter["type"] as? String)?.lowercased() ?? "device"
+ let type = SkillType(rawValue: typeRaw) ?? .device
+
let metadata = SkillMetadata(
- id: skillId,
- name: frontmatter["name"] as? String ?? skillId,
+ id: id,
+ name: frontmatter["name"] as? String ?? id,
localizedNameZh: frontmatter["name-zh"] as? String,
description: frontmatter["description"] as? String ?? "",
version: frontmatter["version"] as? String ?? "1.0.0",
icon: frontmatter["icon"] as? String ?? "wrench",
disabled: frontmatter["disabled"] as? Bool ?? false,
+ type: type,
triggers: frontmatter["triggers"] as? [String] ?? [],
allowedTools: frontmatter["allowed-tools"] as? [String] ?? [],
examples: parseExamples(frontmatter["examples"])
)
return SkillDefinition(
- id: skillId,
- filePath: file,
+ id: id,
+ filePath: filePath,
metadata: metadata,
- body: nil, // 懒加载
+ body: parseBody(content),
isEnabled: !metadata.disabled
)
}
- private func parseFrontmatter(_ content: String) -> [String: Any]? {
- // 匹配 --- ... --- 包裹的 YAML
+ // MARK: - 解析 helpers
+
+ private static func parseFrontmatter(_ content: String) -> [String: Any]? {
guard let regex = try? NSRegularExpression(
pattern: "^---\\s*\\n(.*?)\\n---\\s*\\n",
options: .dotMatchesLineSeparators
@@ -194,12 +143,11 @@ class SkillLoader {
let yamlRange = Range(match.range(at: 1), in: content) else { return nil }
let yamlString = String(content[yamlRange])
- // 用 Yams 解析
guard let parsed = try? Yams.load(yaml: yamlString) as? [String: Any] else { return nil }
return parsed
}
- private func parseBody(_ content: String) -> String {
+ private static func parseBody(_ content: String) -> String {
guard let regex = try? NSRegularExpression(
pattern: "^---\\s*\\n.*?\\n---\\s*\\n(.*)$",
options: .dotMatchesLineSeparators
@@ -213,7 +161,7 @@ class SkillLoader {
return content.trimmingCharacters(in: .whitespacesAndNewlines)
}
- private func parseExamples(_ raw: Any?) -> [SkillExample] {
+ private static func parseExamples(_ raw: Any?) -> [SkillExample] {
guard let list = raw as? [[String: Any]] else { return [] }
return list.compactMap { dict in
guard let query = dict["query"] as? String,
@@ -221,376 +169,5 @@ class SkillLoader {
return SkillExample(query: query, scenario: scenario)
}
}
-
- // MARK: - 首次启动:写入默认 Skill
-
- private func ensureDefaultSkills() {
- let fm = FileManager.default
- if !fm.fileExists(atPath: skillsDirectory.path) {
- try? fm.createDirectory(at: skillsDirectory, withIntermediateDirectories: true)
- }
-
- for (dirName, content) in Self.defaultSkills {
- let dir = skillsDirectory.appendingPathComponent(dirName, isDirectory: true)
- let file = dir.appendingPathComponent("SKILL.md")
- let normalized = content.hasSuffix("\n") ? content : content + "\n"
- let current = try? String(contentsOf: file, encoding: .utf8)
- if current == normalized { continue }
- try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
- try? normalized.write(to: file, atomically: true, encoding: .utf8)
- }
- }
-
- // MARK: - 内置默认 SKILL.md
-
- static let defaultSkills: [(String, String)] = [
- ("clipboard", """
- ---
- name: Clipboard
- name-zh: 剪贴板
- description: '读写系统剪贴板内容。当用户需要读取、复制或操作剪贴板时使用。'
- version: "1.0.0"
- icon: doc.on.clipboard
- disabled: false
-
- triggers:
- - 剪贴板
- - 粘贴
- - 复制
- - clipboard
-
- allowed-tools:
- - clipboard-read
- - clipboard-write
-
- examples:
- - query: "读取我的剪贴板内容"
- scenario: "读取剪贴板"
- - query: "把这段文字复制到剪贴板"
- scenario: "写入剪贴板"
- ---
-
- # 剪贴板操作
-
- 你负责帮助用户读写系统剪贴板。
-
- ## 可用工具
-
- - **clipboard-read**: 读取剪贴板当前内容(无参数)
- - **clipboard-write**: 将文本写入剪贴板(参数: text — 要复制的文本)
-
- ## 执行流程
-
- 1. 用户要求读取 → 调用 `clipboard-read`
- 2. 用户要求复制/写入 → 调用 `clipboard-write`,传入 text 参数
- 3. 根据工具返回结果,简洁回答用户
-
- ## 调用格式
-
-
- {"name": "工具名", "arguments": {}}
-
- """),
-
- ("device", """
- ---
- name: Device
- name-zh: 设备
- description: '使用 iOS 官方公开 API 查询当前设备名称、设备类型、系统版本、内存和处理器数量。'
- version: "1.0.0"
- icon: desktopcomputer
- disabled: false
-
- triggers:
- - 设备
- - 系统信息
- - 当前设备
- - 本机
-
- allowed-tools:
- - device-info
- - device-name
- - device-model
- - device-system-version
- - device-memory
- - device-processor-count
- - device-identifier-for-vendor
-
- examples:
- - query: "这台手机的设备信息是什么"
- scenario: "查看官方设备信息汇总"
- - query: "系统版本是多少"
- scenario: "查看系统版本"
- - query: "这台设备叫什么名字"
- scenario: "查看设备名称"
- - query: "内存多大"
- scenario: "查看物理内存"
- - query: "处理器核心数是多少"
- scenario: "查看处理器数量"
- ---
-
- # 设备信息查询
-
- 你负责帮助用户查看当前这台设备的系统与硬件基础信息。
-
- ## 可用工具
-
- - **device-info**: 汇总查询当前设备名称、设备类型、系统版本、物理内存、处理器数量
- - **device-name**: 查询当前设备名称
- - **device-model**: 查询当前设备类型(官方 `UIDevice.model` / `localizedModel`)
- - **device-system-version**: 查询系统名称和系统版本
- - **device-memory**: 查询物理内存大小
- - **device-processor-count**: 查询处理器核心数
- - **device-identifier-for-vendor**: 查询当前 App 的 `identifierForVendor`
-
- ## 执行流程
-
- 1. 只有当用户明确询问当前设备、本机、手机、系统版本、内存、处理器等信息时,才调用这些工具
- 2. 如果用户只是泛泛提到“配置”或“信息”,但没有明确在问当前设备,不要调用这个工具
- 3. 能用单个专用工具回答时,优先使用最小 sufficient 的那个工具;只有用户想要整体参数时,才调用 `device-info`
- 4. 工具返回后,直接把 JSON 转成用户友好的中文描述
- 5. 这些工具只使用 iOS 官方公开 API,不要自行映射成具体营销机型名
- 6. 如果用户问“手机型号”,要如实说明官方 API 只能返回通用设备类型,例如 `iPhone`
- 7. 如果工具结果已经足够,直接回答,不要反问用户“你指的是什么配置”
-
- ## 调用格式
-
-
- {"name": "device-info", "arguments": {}}
-
- """),
-
- ("text", """
- ---
- name: Text
- name-zh: 文本
- description: '文本处理工具:哈希计算、翻转等。当用户需要对文本进行处理或转换时使用。'
- version: "1.0.0"
- icon: textformat
- disabled: false
-
- triggers:
- - 哈希
- - hash
- - 翻转
- - 反转
- - 文本处理
-
- allowed-tools:
- - calculate-hash
- - text-reverse
-
- examples:
- - query: "计算 Hello World 的哈希值"
- scenario: "哈希计算"
- - query: "把这段文字翻转过来"
- scenario: "文本翻转"
- ---
-
- # 文本处理
-
- 你负责帮助用户进行文本处理操作。
-
- ## 可用工具
-
- - **calculate-hash**: 计算文本的哈希值(参数: text — 要计算哈希的文本)
- - **text-reverse**: 翻转文本(参数: text — 要翻转的文本)
-
- ## 执行流程
-
- 1. 判断用户需要哪种文本操作
- 2. 调用对应工具,传入 text 参数
- 3. 返回处理结果
-
- ## 调用格式
-
-
- {"name": "工具名", "arguments": {"text": "要处理的文本"}}
-
- """),
-
- ("calendar", """
- ---
- name: Calendar
- name-zh: 日历
- description: '创建新的日历事项。当用户需要安排日程、会议、约会或写入日历时使用。'
- version: "1.0.0"
- icon: calendar
- disabled: false
-
- triggers:
- - 日历
- - 日程
- - 会议
- - 约会
- - 安排
-
- allowed-tools:
- - calendar-create-event
-
- examples:
- - query: "帮我创建一个明天下午两点的会议"
- scenario: "新建日历事项"
- ---
-
- # 日历事项创建
-
- 你负责帮助用户创建新的日历事项。
-
- ## 可用工具
-
- - **calendar-create-event**: 创建日历事项
- - `title`: 必填,事项标题
- - `start`: 必填,ISO 8601 开始时间,例如 `2026-04-07T14:00:00`
- - `end`: 可选,ISO 8601 结束时间;不传时默认开始后一小时
- - `location`: 可选,地点
- - `notes`: 可选,备注
-
- ## 执行流程
-
- 1. 只有当用户明确要新建/安排日历事项时才调用工具
- 2. 由你从用户话语中提取参数,工具层不做自然语言解析
- 3. 传给工具前,必须把时间整理成 ISO 8601 字符串
- 4. 如果缺少 `title` 或 `start`,先简短追问,不要猜测
- 5. 工具成功后,直接告诉用户已创建什么事项和时间
-
- ## 调用格式
-
-
- {"name": "calendar-create-event", "arguments": {"title": "会议", "start": "2026-04-07T14:00:00"}}
-
- """),
-
- ("reminders", """
- ---
- name: Reminders
- name-zh: 提醒事项
- description: '创建新的提醒事项。当用户需要记得做某事、设置待办或提醒时使用。'
- version: "1.0.0"
- icon: bell
- disabled: false
-
- triggers:
- - 提醒
- - 待办
- - 记得
- - 提示
-
- allowed-tools:
- - reminders-create
-
- examples:
- - query: "提醒我今晚八点发文件"
- scenario: "新建提醒事项"
- ---
-
- # 提醒事项创建
-
- 你负责帮助用户创建新的提醒事项。
-
- ## 可用工具
-
- - **reminders-create**: 创建提醒事项
- - `title`: 必填,提醒标题
- - `due`: 可选,ISO 8601 提醒时间,例如 `2026-04-07T20:00:00`
- - `notes`: 可选,备注
-
- ## 执行流程
-
- 1. 只有当用户明确要设置提醒或待办时才调用工具
- 2. 由你提取标题、时间、备注
- 3. 如果有时间,必须转换成 ISO 8601 字符串
- 4. 如果缺少 `title`,先简短追问
- 5. 工具成功后,直接告诉用户提醒已创建
-
- ## 调用格式
-
-
- {"name": "reminders-create", "arguments": {"title": "发文件", "due": "2026-04-07T20:00:00"}}
-
- """),
-
- ("contacts", """
- ---
- name: Contacts
- name-zh: 通讯录
- description: '查询、创建、更新或删除联系人。当用户要查电话、看联系方式、存号码、补充联系人信息或删除联系人时使用。'
- version: "1.1.0"
- icon: person.crop.circle
- disabled: false
-
- triggers:
- - 联系人
- - 通讯录
- - 查电话
- - 联系电话
- - 存号码
- - 联系方式
- - 删除联系人
-
- allowed-tools:
- - contacts-search
- - contacts-upsert
- - contacts-delete
-
- examples:
- - 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. 如果用户在查询电话、邮箱、联系方式,优先调用 `contacts-search`
- 2. 如果用户在删除、移除、删掉联系人,调用 `contacts-delete`
- 3. 如果用户在保存、添加或更新联系人,调用 `contacts-upsert`
- 4. 查询或删除时优先提取 `name`,提取不到再用 `query`
- 5. 保存或更新时提取姓名、手机号、公司、邮箱、备注
- 6. 如果缺少保存联系人所需的 `name`,先简短追问
- 7. 删除时如果匹配到多个联系人,不要猜测,应提示用户说得更具体
- 8. 工具成功后,直接用中文给出简洁结果
-
- ## 调用格式
-
-
- {"name": "contacts-search", "arguments": {"name": "张晓霞"}}
-
-
-
- {"name": "contacts-upsert", "arguments": {"name": "王总", "phone": "13812345678", "company": "字节"}}
-
-
-
- {"name": "contacts-delete", "arguments": {"name": "王总"}}
-
- """),
- ]
}
+
diff --git a/Skills/SkillRegistry.swift b/Skills/SkillRegistry.swift
new file mode 100644
index 0000000..79943d0
--- /dev/null
+++ b/Skills/SkillRegistry.swift
@@ -0,0 +1,161 @@
+import Foundation
+
+// MARK: - Skill Registry (有状态: 注册/查询/启用/保存)
+//
+// 对称 ToolRegistry。持有 [id: SkillDefinition], 提供注册制 API。
+// 解析和 bundle 读取由无状态的 SkillLoader (SkillLoader.swift) 提供。
+
+class SkillRegistry {
+ private static let skillAliases: [String: String] = [
+ "contacts_delete": "contacts",
+ "contacts-delete": "contacts"
+ ]
+
+ /// 用户编辑的 override 目录 (runtime 可写)
+ let overridesDirectory: URL
+
+ /// Registry: skill id -> SkillDefinition
+ private var skills: [String: SkillDefinition] = [:]
+
+ init() {
+ let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ self.overridesDirectory = appSupport.appendingPathComponent("PhoneClaw/skills", isDirectory: true)
+ try? FileManager.default.createDirectory(at: overridesDirectory, withIntermediateDirectories: true)
+ registerBuiltInSkills()
+ }
+
+ // MARK: - 公开 API
+
+ /// 返回所有已注册的 skill。
+ /// 名字沿用 discoverSkills() 以保持向后兼容, 实际是 "all registered"。
+ func discoverSkills() -> [SkillDefinition] {
+ Array(skills.values).sorted { $0.id < $1.id }
+ }
+
+ /// 返回 skill body (register 时已解析, 直接从 cache 返回)
+ func loadBody(skillId: String) -> String? {
+ let id = canonicalSkillId(for: skillId)
+ return skills[id]?.body
+ }
+
+ /// 用户保存 SKILL.md。写到 override 目录, 重新 parse 并更新 registry。
+ func saveSkill(skillId: String, content: String) throws {
+ let dir = overridesDirectory.appendingPathComponent(skillId, isDirectory: true)
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ let file = dir.appendingPathComponent("SKILL.md")
+ try content.write(to: file, atomically: true, encoding: .utf8)
+
+ // 重新 parse 并更新 registry (保持原 isEnabled)
+ if var def = SkillLoader.parseDefinition(id: skillId, content: content, filePath: file) {
+ if let existing = skills[skillId] {
+ def.isEnabled = existing.isEnabled
+ }
+ skills[skillId] = def
+ }
+ }
+
+ /// 重载所有 (清空 registry 并重新注册)
+ func reloadAll() -> [SkillDefinition] {
+ skills.removeAll()
+ registerBuiltInSkills()
+ return discoverSkills()
+ }
+
+ /// 根据工具名反查 skill id
+ func findSkillId(forTool toolName: String) -> String? {
+ for (id, def) in skills where def.metadata.allowedTools.contains(toolName) {
+ return id
+ }
+ return nil
+ }
+
+ /// 获取注册的 SkillDefinition
+ func getDefinition(_ skillId: String) -> SkillDefinition? {
+ skills[canonicalSkillId(for: skillId)]
+ }
+
+ /// 更新启用状态
+ func setEnabled(_ skillId: String, enabled: Bool) {
+ skills[canonicalSkillId(for: skillId)]?.isEnabled = enabled
+ }
+
+ /// 别名归一化 (例如 "contacts_delete" -> "contacts")
+ func canonicalSkillId(for skillId: String) -> String {
+ Self.skillAliases[skillId] ?? skillId
+ }
+
+ // MARK: - 注册 API (core)
+
+ /// 核心注册 API。传入一个 SkillDefinition 就注册进 registry。
+ ///
+ /// 来源无感知: bundle / 下载 / import / in-app 创建 都走同一个入口。
+ /// 未来支持运行时动态添加 skill 时, caller 构造 SkillDefinition 后直接调用。
+ func register(_ definition: SkillDefinition) {
+ skills[definition.id] = definition
+ }
+
+ // MARK: - Built-in 注册 (从 bundle)
+
+ /// 注册一个内置 skill: 优先用户 override, 否则从 bundle 加载。
+ private func registerBuiltIn(id: String) {
+ // 1. 优先 override
+ let overrideFile = overridesDirectory
+ .appendingPathComponent(id, isDirectory: true)
+ .appendingPathComponent("SKILL.md")
+
+ if FileManager.default.fileExists(atPath: overrideFile.path),
+ let def = SkillLoader.loadFromFile(id: id, url: overrideFile) {
+ register(def)
+ return
+ }
+
+ // 2. 从 bundle 读取
+ if let def = SkillLoader.loadFromBundle(id: id) {
+ register(def)
+ }
+ }
+
+ /// Built-in skill 注册列表。对称 ToolRegistry.registerDefaultTools 的模式。
+ ///
+ /// 加新内置 skill 的流程:
+ /// 1. 在 `Skills/Library//` 创建 SKILL.md
+ /// 2. 在 Xcode 里把 Library/ 更新后的文件加到 PhoneClaw target (bundle)
+ /// 3. 在下面加一行 `registerBuiltIn(id: "")`
+ private func registerBuiltInSkills() {
+ registerBuiltIn(id: "clipboard")
+ registerBuiltIn(id: "device")
+ registerBuiltIn(id: "text")
+ registerBuiltIn(id: "calendar")
+ registerBuiltIn(id: "reminders")
+ registerBuiltIn(id: "contacts")
+ registerBuiltIn(id: "translate")
+
+ validateRegisteredSkills()
+ }
+
+ // MARK: - Load-time validation
+ //
+ // 校验所有 SKILL.md 的 `allowed-tools` 字段引用的 tool 名都真实存在于
+ // ToolRegistry。SKILL.md 是手工维护的 frontmatter, 没有编译时检查;
+ // 这里在启动时一次性 cross-check, 写错的 tool 名会立刻在控制台暴露,
+ // 而不是等运行时模型 emit tool_call 然后被 cage 静默丢弃。
+ //
+ // 这是 ToolRegistry "白名单"角色的另一个体现: 不只在运行时丢弃非法
+ // 调用, 也在加载时拒绝非法引用。
+
+ private func validateRegisteredSkills() {
+ let registry = ToolRegistry.shared
+ var hadError = false
+ for (id, def) in skills {
+ for toolName in def.metadata.allowedTools {
+ if registry.find(name: toolName) == nil {
+ print("[SkillRegistry] ⚠️ skill '\(id)' 的 allowed-tools 引用了未注册的工具 '\(toolName)'")
+ hadError = true
+ }
+ }
+ }
+ if !hadError {
+ print("[SkillRegistry] ✓ 所有 \(skills.count) 个 skill 的 allowed-tools 校验通过")
+ }
+ }
+}
diff --git a/Skills/Skills.swift b/Skills/Skills.swift
index 4d37e0e..0782f79 100644
--- a/Skills/Skills.swift
+++ b/Skills/Skills.swift
@@ -5,7 +5,7 @@ import Foundation
// 文件驱动架构:
// - SKILL.md 定义 Skill 元数据 + 指令体(热更新)
// - ToolRegistry.swift 注册原生工具实现(编译时)
-// - SkillLoader.swift 解析和加载 SKILL.md
+// - SkillLoader.swift 解析 SKILL.md (无状态) + SkillRegistry 注册/查询 (有状态)
//
// 以下仅为给 UI 和 PromptBuilder 使用的精简数据结构。
@@ -22,6 +22,7 @@ struct SkillEntry: Identifiable {
var name: String // display name, e.g. "Clipboard"
var description: String
var icon: String
+ var type: SkillType
var samplePrompt: String
var tools: [ToolInfo] = []
var isEnabled: Bool = true
@@ -33,6 +34,7 @@ struct SkillEntry: Identifiable {
self.name = def.metadata.displayName
self.description = def.metadata.description
self.icon = def.metadata.icon
+ self.type = def.metadata.type
self.samplePrompt = def.metadata.examples.first?.query ?? ""
self.isEnabled = def.isEnabled
self.filePath = def.filePath
@@ -50,5 +52,6 @@ struct SkillInfo {
let description: String
var displayName: String = ""
var icon: String = "wrench"
+ var type: SkillType = .device
var samplePrompt: String = ""
}
diff --git a/Skills/ToolRegistry.swift b/Skills/ToolRegistry.swift
deleted file mode 100644
index cd99e17..0000000
--- a/Skills/ToolRegistry.swift
+++ /dev/null
@@ -1,1320 +0,0 @@
-import AVFoundation
-import Contacts
-import EventKit
-import Foundation
-import UIKit
-
-// MARK: - 原生工具注册表
-//
-// 所有原生 API 封装集中注册在这里。
-// SKILL.md 通过 allowed-tools 字段引用工具名。
-
-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
- }
-}
-
-struct RegisteredTool {
- let name: String
- let description: String
- let parameters: String
- let execute: ([String: Any]) async throws -> String
-}
-
-class ToolRegistry {
- static let shared = ToolRegistry()
-
- private var tools: [String: RegisteredTool] = [:]
- private let eventStore = EKEventStore()
- private let contactStore = CNContactStore()
-
- private init() {
- registerBuiltInTools()
- }
-
- // MARK: - 公开接口
-
- func register(_ tool: RegisteredTool) {
- tools[tool.name] = tool
- }
-
- func find(name: String) -> RegisteredTool? {
- tools[name]
- }
-
- func execute(name: String, args: [String: Any]) async throws -> String {
- guard let tool = tools[name] else {
- return "{\"success\": false, \"error\": \"未知工具: \(name)\"}"
- }
- return try await tool.execute(args)
- }
-
- /// 根据名称列表获取工具(用于 SKILL.md 的 allowed-tools)
- func toolsFor(names: [String]) -> [RegisteredTool] {
- names.compactMap { tools[$0] }
- }
-
- /// 根据工具名反查:它属于哪些 allowed-tools 列表
- /// 返回 true 如果该工具已注册
- func hasToolNamed(_ name: String) -> Bool {
- tools[name] != nil
- }
-
- /// 所有已注册的工具名
- var allToolNames: [String] {
- Array(tools.keys).sorted()
- }
-
- 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 try await requestMicrophoneAccess()
- case .calendar:
- return try await requestCalendarWriteAccess()
- case .reminders:
- return try await requestRemindersAccess()
- case .contacts:
- return try await requestContactsAccess()
- }
- }
-
- private func requestMicrophoneAccess() async throws -> Bool {
- await withCheckedContinuation { continuation in
- AVAudioSession.sharedInstance().requestRecordPermission { granted in
- continuation.resume(returning: granted)
- }
- }
- }
-
- private func requestCalendarWriteAccess() async throws -> Bool {
- try await withCheckedThrowingContinuation { continuation in
- eventStore.requestWriteOnlyAccessToEvents { granted, error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume(returning: granted)
- }
- }
- }
- }
-
- private func requestRemindersAccess() async throws -> Bool {
- try await withCheckedThrowingContinuation { continuation in
- eventStore.requestFullAccessToReminders { granted, error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume(returning: granted)
- }
- }
- }
- }
-
- private func requestContactsAccess() async throws -> Bool {
- try await withCheckedThrowingContinuation { continuation in
- contactStore.requestAccess(for: .contacts) { granted, error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume(returning: granted)
- }
- }
- }
- }
-
- private 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
- }
-
- private func writableEventCalendar() -> EKCalendar? {
- if let calendar = eventStore.defaultCalendarForNewEvents,
- calendar.allowsContentModifications {
- return calendar
- }
-
- return eventStore.calendars(for: .event)
- .first(where: \.allowsContentModifications)
- }
-
- private func writableReminderCalendar() -> EKCalendar? {
- if let calendar = eventStore.defaultCalendarForNewReminders(),
- calendar.allowsContentModifications {
- return calendar
- }
-
- return eventStore.calendars(for: .reminder)
- .first(where: \.allowsContentModifications)
- }
-
- private func newReminderListTitle() -> String {
- let prefersChinese = Locale.preferredLanguages.contains { $0.hasPrefix("zh") }
- return prefersChinese ? "PhoneClaw 提醒事项" : "PhoneClaw Reminders"
- }
-
- private func reminderCalendarCreationSources() -> [EKSource] {
- let existingReminderSources = Set(
- eventStore.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)] = eventStore.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 func ensureWritableReminderCalendar() throws -> EKCalendar? {
- if let calendar = writableReminderCalendar() {
- return calendar
- }
-
- var lastError: Error?
- for source in reminderCalendarCreationSources() {
- let reminderList = EKCalendar(for: .reminder, eventStore: eventStore)
- reminderList.title = newReminderListTitle()
- reminderList.source = source
-
- do {
- try eventStore.saveCalendar(reminderList, commit: true)
- if reminderList.allowsContentModifications {
- return reminderList
- }
- if let saved = eventStore.calendar(withIdentifier: reminderList.calendarIdentifier),
- saved.allowsContentModifications {
- return saved
- }
- } catch {
- lastError = error
- }
- }
-
- if let lastError {
- throw lastError
- }
-
- return nil
- }
-
- private func iso8601String(from date: Date) -> String {
- let formatter = ISO8601DateFormatter()
- formatter.timeZone = .current
- formatter.formatOptions = [.withInternetDateTime]
- return formatter.string(from: date)
- }
-
- private func reminderDateComponents(from date: Date) -> DateComponents {
- Calendar.current.dateComponents(
- in: .current,
- from: date
- )
- }
-
- private 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 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 contactStore.unifiedContacts(
- matching: predicate,
- keysToFetch: contactKeysToFetch()
- ).first
- }
-
- private func allContacts() throws -> [CNContact] {
- var contacts: [CNContact] = []
- let request = CNContactFetchRequest(keysToFetch: contactKeysToFetch())
- request.sortOrder = .userDefault
- try contactStore.enumerateContacts(with: request) { contact, _ in
- contacts.append(contact)
- }
- return contacts
- }
-
- private func formattedContactName(_ contact: CNContact) -> String {
- let manual = [contact.familyName, contact.middleName, contact.givenName]
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- .joined()
- if !manual.isEmpty {
- return manual
- }
-
- let nickname = contact.nickname.trimmingCharacters(in: .whitespacesAndNewlines)
- if !nickname.isEmpty {
- return nickname
- }
-
- let organization = contact.organizationName.trimmingCharacters(in: .whitespacesAndNewlines)
- if !organization.isEmpty {
- return organization
- }
-
- return "未命名联系人"
- }
-
- private func clipboardTextPreview(
- from text: String,
- maxCharacters: Int = 500
- ) -> (preview: String, truncated: Bool)? {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nil }
-
- let endIndex = trimmed.index(
- trimmed.startIndex,
- offsetBy: maxCharacters,
- limitedBy: trimmed.endIndex
- ) ?? trimmed.endIndex
-
- return (
- preview: String(trimmed[.. [String] {
- [
- formattedContactName(contact),
- contact.familyName,
- contact.middleName,
- contact.givenName,
- contact.nickname,
- contact.organizationName,
- contact.jobTitle
- ]
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- }
-
- private func relaxedSearchAliases(for raw: String) -> [String] {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return [] }
-
- var aliases = [trimmed]
- let suffixes = ["总经理", "经理", "总监", "老板", "老师", "医生", "主任", "总", "哥", "姐"]
- for suffix in suffixes where trimmed.hasSuffix(suffix) {
- let candidate = String(trimmed.dropLast(suffix.count))
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if candidate.count >= 2 {
- aliases.append(candidate)
- }
- }
-
- let prefixes = ["老", "小", "阿"]
- for prefix in prefixes where trimmed.hasPrefix(prefix) {
- let candidate = String(trimmed.dropFirst(prefix.count))
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if candidate.count >= 2 {
- aliases.append(candidate)
- }
- }
-
- return Array(NSOrderedSet(array: aliases)) as? [String] ?? aliases
- }
-
- private func primaryPhone(_ contact: CNContact) -> String? {
- contact.phoneNumbers
- .map { $0.value.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) }
- .first(where: { !$0.isEmpty })
- }
-
- private func primaryEmail(_ contact: CNContact) -> String? {
- contact.emailAddresses
- .map { String($0.value).trimmingCharacters(in: .whitespacesAndNewlines) }
- .first(where: { !$0.isEmpty })
- }
-
- private func contactSummaryDictionary(_ contact: CNContact) -> [String: Any] {
- [
- "identifier": contact.identifier,
- "name": formattedContactName(contact),
- "phone": primaryPhone(contact) ?? "",
- "company": contact.organizationName,
- "email": primaryEmail(contact) ?? ""
- ]
- }
-
- private func contactSummaryText(_ contact: CNContact) -> String {
- var parts = [formattedContactName(contact)]
- if let phone = primaryPhone(contact) {
- parts.append("电话 \(phone)")
- }
- if let email = primaryEmail(contact) {
- parts.append("邮箱 \(email)")
- }
- let company = contact.organizationName.trimmingCharacters(in: .whitespacesAndNewlines)
- if !company.isEmpty {
- parts.append("公司 \(company)")
- }
- return parts.joined(separator: ",")
- }
-
- private func searchContacts(
- identifier: String? = nil,
- name: String? = nil,
- phone: String? = nil,
- email: String? = nil,
- query: String? = nil
- ) throws -> [CNContact] {
- let identifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines)
- let name = name?.trimmingCharacters(in: .whitespacesAndNewlines)
- let phone = phone?.trimmingCharacters(in: .whitespacesAndNewlines)
- let email = email?.trimmingCharacters(in: .whitespacesAndNewlines)
- let query = query?.trimmingCharacters(in: .whitespacesAndNewlines)
-
- let candidates: [CNContact]
- if let identifier, !identifier.isEmpty {
- candidates = try contactStore.unifiedContacts(
- matching: CNContact.predicateForContacts(withIdentifiers: [identifier]),
- keysToFetch: contactKeysToFetch()
- )
- } else {
- candidates = try allContacts()
- }
-
- let matches = candidates.filter { contact in
- if let identifier, !identifier.isEmpty, contact.identifier != identifier {
- return false
- }
-
- if let name, !name.isEmpty {
- let aliases = relaxedSearchAliases(for: name)
- let searchTexts = contactSearchTexts(contact)
- let matched = aliases.contains { alias in
- searchTexts.contains { $0.localizedCaseInsensitiveContains(alias) }
- }
- if !matched {
- return false
- }
- }
-
- if let phone, !phone.isEmpty,
- !contact.phoneNumbers.contains(where: {
- $0.value.stringValue.localizedCaseInsensitiveContains(phone)
- }) {
- return false
- }
-
- if let email, !email.isEmpty,
- !contact.emailAddresses.contains(where: {
- String($0.value).localizedCaseInsensitiveContains(email)
- }) {
- return false
- }
-
- if let query, !query.isEmpty {
- let aliases = relaxedSearchAliases(for: query)
- let textMatch = aliases.contains { alias in
- contactSearchTexts(contact).contains {
- $0.localizedCaseInsensitiveContains(alias)
- }
- }
- let phoneMatch = contact.phoneNumbers.contains {
- $0.value.stringValue.localizedCaseInsensitiveContains(query)
- }
- let emailMatch = contact.emailAddresses.contains {
- String($0.value).localizedCaseInsensitiveContains(query)
- }
- if !(textMatch || phoneMatch || emailMatch) {
- return false
- }
- }
-
- return true
- }
-
- return matches.sorted {
- formattedContactName($0).localizedCaseInsensitiveCompare(formattedContactName($1)) == .orderedAscending
- }
- }
-
- // MARK: - 内置工具注册
-
- private func registerBuiltInTools() {
- 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)
- }
-
- func officialDevicePayload() 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
- }
-
- // ── Clipboard ──
- register(RegisteredTool(
- name: "clipboard-read",
- description: "读取剪贴板当前内容",
- parameters: "无"
- ) { _ 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 = 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)\(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: "剪贴板为空")
- }
- })
-
- register(RegisteredTool(
- name: "clipboard-write",
- description: "将文本写入剪贴板",
- parameters: "text: 要复制的文本内容"
- ) { 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]
- )
- })
-
- // ── Device ──
- register(RegisteredTool(
- name: "device-info",
- description: "使用 iOS 官方公开 API 汇总获取当前设备名称、设备类型、系统版本、内存和处理器数量",
- parameters: "无"
- ) { _ in
- let payload = await officialDevicePayload()
- 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)
- })
-
- register(RegisteredTool(
- name: "device-name",
- description: "使用 UIDevice.current.name 获取当前设备名称",
- parameters: "无"
- ) { _ in
- let payload = await officialDevicePayload()
- let name = payload["name"] as? String ?? ""
- return successPayload(
- result: "这台设备的名称是 \(name)。",
- extras: ["name": name]
- )
- })
-
- register(RegisteredTool(
- name: "device-model",
- description: "使用 UIDevice.current.model 和 localizedModel 获取当前设备类型",
- parameters: "无"
- ) { _ in
- let payload = await officialDevicePayload()
- 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
- ])
- })
-
- register(RegisteredTool(
- name: "device-system-version",
- description: "使用 UIDevice.current.systemName 和 systemVersion 获取系统版本",
- parameters: "无"
- ) { _ in
- let payload = await officialDevicePayload()
- 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
- ])
- })
-
- register(RegisteredTool(
- name: "device-memory",
- description: "使用 ProcessInfo.processInfo.physicalMemory 获取设备物理内存",
- parameters: "无"
- ) { _ in
- let payload = await officialDevicePayload()
- 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
- ])
- })
-
- register(RegisteredTool(
- name: "device-processor-count",
- description: "使用 ProcessInfo.processInfo.processorCount 获取处理器核心数",
- parameters: "无"
- ) { _ in
- let payload = await officialDevicePayload()
- let processorCount = payload["processor_count"] as? Int ?? 0
- return jsonString([
- "success": true,
- "status": "succeeded",
- "result": "这台设备的处理器核心数是 \(processorCount)。",
- "processor_count": processorCount
- ])
- })
-
- register(RegisteredTool(
- name: "device-identifier-for-vendor",
- description: "使用 UIDevice.current.identifierForVendor 获取当前 App 在该设备上的 vendor 标识",
- parameters: "无"
- ) { _ in
- let payload = await officialDevicePayload()
- let identifier = payload["identifier_for_vendor"] as? String ?? ""
- return jsonString([
- "success": true,
- "status": "succeeded",
- "result": "当前 App 在这台设备上的 identifierForVendor 是 \(identifier)。",
- "identifier_for_vendor": identifier
- ])
- })
-
- // ── Text ──
- register(RegisteredTool(
- name: "calculate-hash",
- description: "计算文本的哈希值",
- parameters: "text: 要计算哈希的文本"
- ) { args in
- guard let text = args["text"] as? String else {
- return failurePayload(error: "缺少 text 参数")
- }
- let hash = text.hashValue
- return successPayload(
- result: "文本“\(text)”的哈希值是 \(hash)。",
- extras: [
- "input": text,
- "hash": hash
- ]
- )
- })
-
- register(RegisteredTool(
- name: "text-reverse",
- description: "翻转文本",
- parameters: "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
- ]
- )
- })
-
- // ── Calendar / Reminders / Contacts ──
- register(RegisteredTool(
- name: "calendar-create-event",
- description: "创建新的日历事项,可写入标题、开始时间、结束时间、地点和备注",
- parameters: "title: 事件标题, start: ISO 8601 开始时间, end: ISO 8601 结束时间(可选), location: 地点(可选), notes: 备注(可选)"
- ) { 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 参数")
- }
- guard let startRaw = args["start"] as? String,
- let startDate = self.parseISO8601Date(startRaw) else {
- return failurePayload(error: "缺少有效的 start 参数,必须是 ISO 8601 时间字符串")
- }
-
- let endRaw = (args["end"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
- let endDate = endRaw.flatMap(self.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 self.requestCalendarWriteAccess() else {
- return failurePayload(error: "未获得日历写入权限")
- }
-
- guard let calendar = self.writableEventCalendar() else {
- return failurePayload(error: "没有可用于新建事项的可写日历,请先在系统日历中启用或创建一个日历")
- }
-
- let event = EKEvent(eventStore: self.eventStore)
- 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 self.eventStore.save(event, span: .thisEvent, commit: true)
-
- return successPayload(
- result: "已创建日历事项“\(title)”,开始时间为 \(self.iso8601String(from: startDate))。",
- extras: [
- "eventId": event.eventIdentifier ?? "",
- "title": title,
- "start": self.iso8601String(from: startDate),
- "end": self.iso8601String(from: endDate),
- "location": location ?? "",
- "notes": notes ?? ""
- ]
- )
- } catch {
- return failurePayload(error: "创建日历事项失败:\(error.localizedDescription)")
- }
- })
-
- register(RegisteredTool(
- name: "reminders-create",
- description: "创建新的提醒事项,可写入标题、到期时间和备注",
- parameters: "title: 提醒标题, due: ISO 8601 到期时间(可选), notes: 备注(可选)"
- ) { 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, self.parseISO8601Date(dueRaw) == nil {
- return failurePayload(error: "due 必须是有效的 ISO 8601 时间字符串")
- }
-
- let dueDate = dueRaw.flatMap(self.parseISO8601Date)
- let notes = (args["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
-
- do {
- guard try await self.requestRemindersAccess() else {
- return failurePayload(error: "未获得提醒事项权限")
- }
-
- guard let calendar = try self.ensureWritableReminderCalendar() else {
- return failurePayload(error: "没有可用于新建提醒事项的可写列表,且无法自动创建提醒列表,请先在系统提醒事项 App 中启用或创建一个列表")
- }
-
- let reminder = EKReminder(eventStore: self.eventStore)
- reminder.calendar = calendar
- reminder.title = title
- if let dueDate {
- reminder.dueDateComponents = self.reminderDateComponents(from: dueDate)
- reminder.addAlarm(EKAlarm(absoluteDate: dueDate))
- }
- if let notes, !notes.isEmpty {
- reminder.notes = notes
- }
-
- try self.eventStore.save(reminder, commit: true)
-
- return successPayload(
- result: dueDate != nil
- ? "已创建提醒事项“\(title)”,提醒时间为 \(self.iso8601String(from: dueDate!))。"
- : "已创建提醒事项“\(title)”。",
- extras: [
- "calendarItemId": reminder.calendarItemIdentifier,
- "title": title,
- "due": dueDate.map { self.iso8601String(from: $0) } ?? "",
- "notes": notes ?? ""
- ]
- )
- } catch {
- return failurePayload(error: "创建提醒事项失败:\(error.localizedDescription)")
- }
- })
-
- register(RegisteredTool(
- name: "contacts-upsert",
- description: "创建或更新联系人;若提供手机号则优先按手机号查重再更新",
- parameters: "name: 联系人姓名, phone: 手机号(可选), company: 公司(可选), email: 邮箱(可选), notes: 备注(可选)"
- ) { 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 self.requestContactsAccess() else {
- return failurePayload(error: "未获得通讯录权限")
- }
-
- let existingContact = phone.flatMap { try? self.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 self.contactStore.execute(saveRequest)
-
- let actionText = action == "updated" ? "已更新" : "已创建"
- return successPayload(
- result: "\(actionText)联系人“\(name)”。",
- extras: [
- "action": action,
- "name": name,
- "phone": phone ?? "",
- "company": company ?? "",
- "email": email ?? "",
- "notes": notes ?? ""
- ]
- )
- } catch {
- 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)")
- }
- })
- }
-}
-
-// MARK: - Helpers
-
-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
-}
diff --git a/Tools/AppPermissions.swift b/Tools/AppPermissions.swift
new file mode 100644
index 0000000..74a0ceb
--- /dev/null
+++ b/Tools/AppPermissions.swift
@@ -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)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Tools/Handlers/Calendar.swift b/Tools/Handlers/Calendar.swift
new file mode 100644
index 0000000..c140acf
--- /dev/null
+++ b/Tools/Handlers/Calendar.swift
@@ -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)
+ }
+}
diff --git a/Tools/Handlers/Clipboard.swift b/Tools/Handlers/Clipboard.swift
new file mode 100644
index 0000000..fbe416b
--- /dev/null
+++ b/Tools/Handlers/Clipboard.swift
@@ -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[.. 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
+ }
+ }
+}
diff --git a/Tools/Handlers/Device.swift b/Tools/Handlers/Device.swift
new file mode 100644
index 0000000..2a8eef0
--- /dev/null
+++ b/Tools/Handlers/Device.swift
@@ -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
+ }
+}
diff --git a/Tools/Handlers/Reminders.swift b/Tools/Handlers/Reminders.swift
new file mode 100644
index 0000000..2e19c47
--- /dev/null
+++ b/Tools/Handlers/Reminders.swift
@@ -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
+ )
+ }
+}
diff --git a/Tools/Handlers/Text.swift b/Tools/Handlers/Text.swift
new file mode 100644
index 0000000..93319da
--- /dev/null
+++ b/Tools/Handlers/Text.swift
@@ -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
+ ]
+ )
+ })
+ }
+}
diff --git a/Tools/Helpers.swift b/Tools/Helpers.swift
new file mode 100644
index 0000000..284eb36
--- /dev/null
+++ b/Tools/Helpers.swift
@@ -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)
+}
diff --git a/Tools/Stores.swift b/Tools/Stores.swift
new file mode 100644
index 0000000..a707424
--- /dev/null
+++ b/Tools/Stores.swift
@@ -0,0 +1,9 @@
+import Contacts
+import EventKit
+
+/// 共享系统 store 单例,供所有 ToolHandler 和 AppPermissions 使用。
+/// 解耦 ToolRegistry,避免重复实例。
+enum SystemStores {
+ static let event = EKEventStore()
+ static let contacts = CNContactStore()
+}
diff --git a/Tools/ToolRegistry.swift b/Tools/ToolRegistry.swift
new file mode 100644
index 0000000..d004d8a
--- /dev/null
+++ b/Tools/ToolRegistry.swift
@@ -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)
+ }
+}
diff --git a/UI/ContentView.swift b/UI/ContentView.swift
index e03f83f..91647ba 100644
--- a/UI/ContentView.swift
+++ b/UI/ContentView.swift
@@ -290,17 +290,33 @@ struct ContentView: View {
// MARK: - Skill 快捷标签
+ /// chip 显示用的简写标签 (UI 表现层选择, 不影响核心数据)。
+ /// key = SkillEntry.id, value = 在 chip pill 上显示的短动作短语。
+ /// 没在表里的 skill 会 fallback 到 skill.displayName。
+ /// chip 实际发送的 prompt 仍然是 skill.samplePrompt (完整、自包含)。
+ private static let chipShortLabels: [String: String] = [
+ "calendar": "帮我创建日程",
+ "contacts": "帮我保存联系人",
+ "reminders": "帮我创建提醒事项",
+ "device": "查询我手机信息",
+ ]
+
+ /// chip 隐藏名单 (UI 表现层选择): 这些 skill 不会出现在欢迎页 chip 列表里,
+ /// 但仍然在引擎里启用、可被用户手动输入触发。
+ private static let chipHiddenSkillIds: Set = ["text"]
+
private var skillChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
- ForEach(engine.enabledSkillInfos, id: \.name) { skill in
+ ForEach(engine.enabledSkillInfos.filter { !Self.chipHiddenSkillIds.contains($0.name) }, id: \.name) { skill in
+ let label = Self.chipShortLabels[skill.name] ?? skill.displayName
Button {
inputText = skill.samplePrompt
Task { await send() }
} label: {
HStack(spacing: 5) {
Image(systemName: skill.icon).font(.system(size: 11))
- Text(skill.displayName).font(.system(size: 12, weight: .medium))
+ Text(label).font(.system(size: 12, weight: .medium))
}
.foregroundStyle(Theme.textSecondary)
.padding(.horizontal, 12)
@@ -682,9 +698,17 @@ struct UserBubble: View {
Text(text)
.font(.system(size: 15))
.foregroundStyle(Theme.userText)
+ .textSelection(.enabled)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Theme.userBubble, in: UserBubbleShape())
+ .contextMenu {
+ Button {
+ UIPasteboard.general.string = text
+ } label: {
+ Label("复制", systemImage: "doc.on.doc")
+ }
+ }
}
}
}
@@ -1254,6 +1278,13 @@ struct AIResponseView: View {
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 4)
.animation(nil, value: text)
+ .contextMenu {
+ Button {
+ UIPasteboard.general.string = text
+ } label: {
+ Label("复制", systemImage: "doc.on.doc")
+ }
+ }
}
}
.animation(.easeInOut(duration: 0.3), value: block.skills.count)
@@ -1460,6 +1491,16 @@ struct SpinnerIcon: View {
// MARK: - 思考动画
+/// 演示 chip 按钮样式: press 时 spring 缩放 + 轻微透明度变化, 让卡片有"活气"。
+struct SkillCardButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .scaleEffect(configuration.isPressed ? 0.96 : 1.0)
+ .opacity(configuration.isPressed ? 0.85 : 1.0)
+ .animation(.spring(response: 0.32, dampingFraction: 0.72), value: configuration.isPressed)
+ }
+}
+
struct ThinkingIndicator: View {
@State private var active = 0
let timer = Timer.publish(every: 0.45, on: .main, in: .common).autoconnect()
diff --git a/UI/SkillsManagerView.swift b/UI/SkillsManagerView.swift
index 2df696f..2fbc0fd 100644
--- a/UI/SkillsManagerView.swift
+++ b/UI/SkillsManagerView.swift
@@ -43,7 +43,7 @@ struct SkillsManagerView: View {
isExpanded: expandedSkills.contains(engine.skillEntries[i].id),
onToggleExpand: { toggleExpand(engine.skillEntries[i].id) },
onSave: { content in
- try? engine.skillLoader.saveSkill(skillId: engine.skillEntries[i].id, content: content)
+ try? engine.skillRegistry.saveSkill(skillId: engine.skillEntries[i].id, content: content)
engine.reloadSkills()
}
)
diff --git a/doc/architecture-decisions.md b/doc/architecture-decisions.md
new file mode 100644
index 0000000..e77b419
--- /dev/null
+++ b/doc/architecture-decisions.md
@@ -0,0 +1,116 @@
+# PhoneClaw Architecture Decision Records (ADR)
+
+> 日期:2026-04-07
+> 上下文:五轮交叉评审(对比 OpenClaw / Claude Skills / Vera),确认端侧小模型推理的架构边界。
+
+---
+
+## ADR-001:拒绝统一 ReAct 循环取代多路分叉
+
+**决策**:保留 processInput 的 5 路分流(VLM / Planner / Preflight / Agent / Light)。
+
+**理由**:
+- 端侧每次 LLM 调用延迟 3-8 秒,统一 ReAct 会将所有请求(包括纯聊天)暴露在 Skill 注入开销下
+- Light 路径不注入任何 Skill 信息,节省 context window
+- Preflight 路径跳过 LLM,<100ms 响应高频操作
+- 2B 模型无法在 ReAct 循环中可靠地自主链式调用多个工具
+- Vera 的统一 ReAct 依赖云端 100B+ 模型(200-500ms/次),前提不同
+
+**重评条件**:端侧模型能力达到可靠自主链式调用(预计需要 8B+ 量级)且推理延迟降至 <1s。
+
+---
+
+## ADR-002:拒绝 [STATUS:COMPLETED] 终止标记
+
+**决策**:继续使用 `parseToolCall() == nil` 作为工具链终止条件。
+
+**理由**:
+- Gemma 4 E2B/E4B 对受约束格式的遵从度不足(空格/大小写/位置不稳定)
+- 强制要求输出 `[STATUS:COMPLETED]` 引入新的故障面(不输出 / 位置错 / 格式变体)
+- 现有终止逻辑(无 tool_call + maxRounds=10 + validation failure + shouldSkipToolFollowUp)已足够健壮
+
+**重评条件**:Gemma 格式遵从度显著提升,或切换到支持结构化输出的模型。
+
+---
+
+## ADR-003:拒绝合并 Planner Selection + Planning 为单一 Prompt
+
+**决策**:保留 Selection → Planning 两步 LLM 调用。
+
+**理由**:
+- 分两步是"外置 CoT"——让小模型一次只做一件事,更可靠
+- ≤3 个候选时 Selection 已走本地快捷路径(L732),不调 LLM
+- 合并后单个 prompt 更长,JSON 遵从度下降
+- Selection 有独立的 `validateSkillSelection()` 验证,失败可以便宜重试;合并后重试成本翻倍
+
+**重评条件**:埋点证实 `matchedSkills > 3` 触发频率 > 5%(此时改为 Selection 短路跳过,不合并 prompt)。
+
+---
+
+## ADR-004:拒绝 E2B Keyword Sequential Fallback
+
+**决策**:E2B 遇到多 Skill 请求时不尝试自动串行执行。
+
+**理由**:
+- 步骤间参数传递需要 intent 信息("把 step1 的什么传给 step2"),仅靠 keyword 无法确定
+- 让 2B 模型用 LLM 来补全参数传递 → 回到了"2B 能否自主链式调用"的问题,形成死循环
+- "降级执行第一个 + 明确告知"是更诚实的 UX 选择
+
+**重评条件**:2B 模型 CoT 能力提升到能可靠理解步骤间依赖关系。
+
+---
+
+## ADR-005:拒绝硬编码模型 ID 做路径分岔
+
+**决策**:使用 `supportsStructuredPlanning` 等能力标志替代 `!= e2bModelID`。
+
+**理由**:
+- 硬编码模型 ID 是技术债,加新模型需要改逻辑代码
+- 能力标志只需修改模型元数据表,未来可扩展
+
+**状态**:P1-a,已纳入 v4 实施计划。
+
+---
+
+## ADR-006:拒绝 YAML 声明式正则快速路径(parameter-hints)
+
+**决策**:`heuristicArgumentsForTool` 的 per-tool 正则保持 Swift 硬编码,不迁移到 SKILL.md。
+
+**理由**:
+- 中文正则在 YAML 里不可维护(无 IDE 补全、无类型安全、无测试覆盖)
+- 当前 Swift 正则是反复调参的产物(如 contacts-search 有两层 fallback),YAML 声明式无法复现
+- ReDoS 风险需要额外防护层
+- 内置 vs 第三方的体验分层是合理的产品取舍(参考 Anthropic Claude)
+
+**推迟到 N≥15 的改动**:
+1. **top-K skill 注入**:system prompt 改为只注入 top-K 匹配项
+2. **`quick-paths` frontmatter**(仅 keyword,无 regex):第三方 Skill 声明关键词→工具名映射
+
+**永久拒绝**:YAML regex `parameter-hints`。
+
+**不重评条件**:若 Skill 数量长期保持 <15,推迟项永不重评。
+
+---
+
+## ADR-007:无 Prompt Prefix Caching
+
+**决策**:不针对 KV cache 前缀命中率调整 prompt 结构。
+
+**理由**:
+- `MLXLocalLLMService.generateStream` 每次推理前调用 `MLX.GPU.clearCache()`
+- 每次推理从头构建完整 KV cache,prompt 前缀稳定性不影响延迟
+- 任何"缓存友好 prompt 重构"需先实现实际的 prefix caching
+
+**重评条件**:PhoneClaw 实现跨请求 KV cache 持久化 + 前缀匹配。
+
+---
+
+## 三级 Skill 体验模型
+
+| 级别 | 来源 | LLM 次数 | 响应 |
+|------|------|---------|------|
+| 一等 | 内置 Swift heuristic | 0 | <1s |
+| 二等(未来) | 第三方 + `quick-paths` keyword | 1 | 3-8s |
+| 三等 | 第三方裸装 | 2 | 6-16s |
+
+这不是缺陷,是取舍。内置 Skill 经过手工调优,不可迁移到 YAML。第三方 Skill 走 LLM 路径功能完整,只是慢一些。