Files
KellyvvandClaude Opus 4.6 779be78843 refactor: split MLX service, introduce skill type & runtime budgets
Major restructuring of the agent stack to support content-type skills,
data-driven runtime budgets, and the new translate skill.

LLM/MLX/
- Split MLXLocalLLMService.swift (1500 → 700 lines) into Core/, Gemma4/,
  Installation/ subdirectories
- New BundledModel: LinearBudgetFormula replaces tier tables, model
  parameters fully data-driven via ModelRuntimeProfile
- New RuntimeBudgets: pure functions for text/thinking/multimodal budgets,
  zero hardcoded model IDs
- Drop adjustedTextOutputTokens prepared-subtraction (chunked prefill made
  it obsolete and triggered false-positive truncation on long prompts)

Skills/
- Add SkillType (.device / .content) metadata, parsed from SKILL.md
  frontmatter
- New translate skill (type: content, no tools) with anaphora handling
- Trim translate (4563→1804), device (2357→1680), calendar (2169→1790)
- contacts SKILL.md adds multi-turn disambiguation + cancel guidance,
  semantics owned by body not framework
- SkillRegistry validates allowed-tools against ToolRegistry at load time

Agent/
- ToolCallParser becomes registry-aware: drops tool_call names not in
  ToolRegistry or protocol set (load_skill / list_skills); ToolRegistry
  is now the single white list, validation moves to parser boundary
- Router sticky routing uses semantic user-turn boundary instead of
  fixed suffix(4); correctly handles long agent loops
- AgentEngine system prompt template differentiates device vs content
  skills with explicit invocation rules
- SYSPROMPT.md auto-migration: old single-placeholder template backed up
  to .bak when new dual-placeholder is detected
- PromptBuilder.buildLoadedSkillPrompt receives availableTools list,
  explicitly tells model which tools (or none) are callable

Tools/
- ToolRegistry split into Helpers/Stores/AppPermissions/Handlers/* for
  separation of concerns
- Mark Tools/Handlers/* with explicit allowed argument signatures

UI/
- Add long-press copy contextMenu on user and assistant bubbles
- Enable .textSelection on user bubble

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:22:20 +08:00

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)
}
}