Files
Termind/TFM/Features/Settings/SkillSettings.swift
T
Kaede Akino e9c269ab5e Replace starter skills with built-in skill helpers
Keep previously installed starter skills as ordinary user-owned records. Provide hidden read-only Skill Creator and Skill Installer instructions, use AgentKit 0.13.0 for reviewed complete GitHub package installation, and remove skill seeding and Reset.

Move Hermes Memory below MCP, use Enabled for its switch, and localize its Chinese title as persistent memory. Preserve existing user edits and synchronize user skill packages through the existing atomic store.

Validation: AgentKit 213 tests and iOS 452 tests passed. macOS app 1183 tests passed; shared frameworks passed 231/232 with the existing SpeechWaveformViewTests PNG byte-equality failure. Related skill, sync, localization and UI checks passed. Strict Swift formatting, Chinese localization checks, and git diff --check passed.
2026-09-17 08:34:10 +08:00

160 lines
5.9 KiB
Swift

import AgentKit
import SwiftUI
import TermindInfrastructure
import UniformTypeIdentifiers
/// The "Skills" section: procedures the assistant may load and follow.
///
/// The footnote is doing real work. A skill is the one thing in Termind whose
/// text the model treats as instructions rather than data, so the page has to
/// say both halves of that: it is followed, and it still cannot approve
/// anything. Someone importing a file from the internet should read that before
/// they enable it, not after.
struct SkillSettings: View {
@Environment(AppModel.self) private var app
let edit: (AgentSkill.ID, Bool) -> Void
@State private var importError: String?
@State private var importWarnings: [String] = []
var body: some View {
Section {
if !app.skills.conflictingSkillIDs.isEmpty {
Label(
"Some skills have duplicate names and are paused. Rename them to resolve the conflict.",
systemImage: "exclamationmark.triangle"
)
.foregroundStyle(.orange)
}
ForEach(app.skills.skills) { skill in
SkillRow(skill: skill) { edit(skill.id, false) }
}
FormNavigationAction(title: "Add Skill") {
let skill = AgentSkill(name: "")
app.skills.upsert(skill)
edit(skill.id, true)
}
Button("Import Skill") { importSkill() }
Text(
"A skill is a procedure the assistant loads when a task matches its description, and then follows. It never approves an action: commands, writes and transfers still ask exactly as they would otherwise."
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
ForEach(importWarnings, id: \.self) { warning in
Label(warning, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
if let importError {
Label(importError, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
if let error = app.skills.storageError {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
} header: {
Text("Skills")
}
}
/// Reads a `SKILL.md` — or a folder holding one — and copies it into the
/// library.
///
/// A copy rather than a link, and no bookmark is kept. Termind is sandboxed,
/// so a live folder would mean holding a security-scoped bookmark and
/// rescanning someone else's directory on every launch; taking the content
/// once, here, is both smaller and easier to reason about — what the agent
/// can read is what the user imported, not whatever that path holds today.
private func importSkill() {
importError = nil
importWarnings = []
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = true
panel.canChooseFiles = true
panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText]
panel.message = String(localized: "Choose a SKILL.md file, or a folder that contains one.")
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let outcome = try SkillImporter.read(url)
let skill = app.skills.install(outcome.document, importedFrom: outcome.origin)
importWarnings = outcome.document.warnings + outcome.warnings
edit(skill.id, false)
} catch {
importError = error.localizedDescription
}
}
}
private struct SkillRow: View {
let skill: AgentSkill
let edit: () -> Void
@Environment(AppModel.self) private var app
var body: some View {
HStack(spacing: Metrics.Space.m) {
VStack(alignment: .leading, spacing: 1) {
Text(skill.displayTitle)
.lineLimit(1)
Text(skill.effectiveName)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
Text(
skill.summary.isEmpty
? String(localized: "No description") : skill.summary
)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: Metrics.Space.m)
if !skill.isComplete {
CardChip(text: String(localized: "Draft"), symbol: "pencil.line")
}
Toggle("", isOn: enabled)
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.mini)
.disabled(!skill.isComplete)
.accessibilityLabel(Text("Enable \(skill.displayTitle)"))
Button(action: edit) {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Edit \(skill.displayTitle)"))
}
.contentShape(.rect)
.onTapGesture(perform: edit)
}
private var enabled: Binding<Bool> {
Binding(
get: { skill.isEnabled && skill.isComplete },
set: { app.skills.setEnabled($0, for: skill.id) }
)
}
}