Major restructuring of the agent stack to support content-type skills, data-driven runtime budgets, and the new translate skill. LLM/MLX/ - Split MLXLocalLLMService.swift (1500 → 700 lines) into Core/, Gemma4/, Installation/ subdirectories - New BundledModel: LinearBudgetFormula replaces tier tables, model parameters fully data-driven via ModelRuntimeProfile - New RuntimeBudgets: pure functions for text/thinking/multimodal budgets, zero hardcoded model IDs - Drop adjustedTextOutputTokens prepared-subtraction (chunked prefill made it obsolete and triggered false-positive truncation on long prompts) Skills/ - Add SkillType (.device / .content) metadata, parsed from SKILL.md frontmatter - New translate skill (type: content, no tools) with anaphora handling - Trim translate (4563→1804), device (2357→1680), calendar (2169→1790) - contacts SKILL.md adds multi-turn disambiguation + cancel guidance, semantics owned by body not framework - SkillRegistry validates allowed-tools against ToolRegistry at load time Agent/ - ToolCallParser becomes registry-aware: drops tool_call names not in ToolRegistry or protocol set (load_skill / list_skills); ToolRegistry is now the single white list, validation moves to parser boundary - Router sticky routing uses semantic user-turn boundary instead of fixed suffix(4); correctly handles long agent loops - AgentEngine system prompt template differentiates device vs content skills with explicit invocation rules - SYSPROMPT.md auto-migration: old single-placeholder template backed up to .bak when new dual-placeholder is detected - PromptBuilder.buildLoadedSkillPrompt receives availableTools list, explicitly tells model which tools (or none) are callable Tools/ - ToolRegistry split into Helpers/Stores/AppPermissions/Handlers/* for separation of concerns - Mark Tools/Handlers/* with explicit allowed argument signatures UI/ - Add long-press copy contextMenu on user and assistant bubbles - Enable .textSelection on user bubble Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
215 lines
7.1 KiB
Swift
215 lines
7.1 KiB
Swift
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)
|
|
}
|
|
}
|