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