194 lines
9.3 KiB
Swift
194 lines
9.3 KiB
Swift
import AgentKit
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindInfrastructure
|
|
import TermindPresentation
|
|
import UniformTypeIdentifiers
|
|
|
|
struct MobileSkillSettings: View {
|
|
let skills: SkillLibraryModel
|
|
@State private var editing: AgentSkill?
|
|
@State private var importing = false
|
|
@State private var warnings: [String] = []
|
|
@State private var error: String?
|
|
private let markdown = UTType(filenameExtension: "md") ?? .plainText
|
|
|
|
var body: some View {
|
|
List {
|
|
Section {
|
|
if !skills.conflictingSkillIDs.isEmpty {
|
|
Text("Some skills have duplicate names and are paused. Rename them to resolve the conflict.")
|
|
.foregroundStyle(.orange)
|
|
}
|
|
ForEach(skills.skills) { skill in
|
|
Button {
|
|
editing = skill
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "doc.text").foregroundStyle(.secondary).accessibilityHidden(true)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(skill.displayTitle)
|
|
Text(skill.effectiveName).font(.caption.monospaced()).foregroundStyle(.secondary)
|
|
Text(skill.summary.isEmpty ? String(localized: "No description") : skill.summary)
|
|
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
|
if !skill.isComplete {
|
|
Text("Draft").font(.caption)
|
|
} else if !skill.isEnabled {
|
|
Text("Disabled").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Image(systemName: "chevron.right").accessibilityHidden(true)
|
|
.font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
|
|
}.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu {
|
|
Button("Edit", systemImage: "pencil") { editing = skill }
|
|
Button("Delete", systemImage: "trash", role: .destructive) {
|
|
skills.delete(id: skill.id)
|
|
}
|
|
}
|
|
.swipeActions(edge: .trailing) {
|
|
Button("Delete", systemImage: "trash", role: .destructive) {
|
|
skills.delete(id: skill.id)
|
|
}
|
|
}
|
|
}
|
|
} footer: {
|
|
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."
|
|
)
|
|
}
|
|
ForEach(warnings, id: \.self) { Text($0).font(.caption).foregroundStyle(.orange) }
|
|
if let error = error ?? skills.storageError { Text(error).foregroundStyle(.red) }
|
|
}
|
|
.navigationTitle("Skills")
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button("Add Skill", systemImage: "plus") { editing = AgentSkill(name: "") }
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
.mobileProminentAction()
|
|
MobileToolbarOverflow {
|
|
Button("Import Skill", systemImage: "square.and.arrow.down") { importing = true }
|
|
}
|
|
}
|
|
.sheet(item: $editing) { MobileSkillEditor(library: skills, skill: $0) }
|
|
.fileImporter(isPresented: $importing, allowedContentTypes: [markdown, .plainText, .folder]) { result in
|
|
do {
|
|
error = nil
|
|
warnings = []
|
|
let outcome = try SkillImporter.read(result.get())
|
|
let installed = skills.install(outcome.document, importedFrom: outcome.origin)
|
|
if let error = skills.storageError { throw MobileEditorError.message(error) }
|
|
warnings = outcome.document.warnings + outcome.warnings
|
|
editing = installed
|
|
} catch { self.error = error.localizedDescription }
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MobileSkillEditor: View {
|
|
let library: SkillLibraryModel
|
|
@State private var model: MobileSkillEditorModel
|
|
@State private var exporting = false
|
|
@State private var error: String?
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
init(library: SkillLibraryModel, skill: AgentSkill) {
|
|
self.library = library
|
|
_model = State(initialValue: MobileSkillEditorModel(library: library, skill: skill))
|
|
}
|
|
|
|
var body: some View {
|
|
MobileEditorSheet(
|
|
title: String(localized: "Skill"), isDirty: model.isDirty, canSave: model.canSave,
|
|
actions: toolbarActions,
|
|
save: { try model.save() },
|
|
content: {
|
|
Section("Skill") {
|
|
LabeledContent("Title") {
|
|
TextField("Title", text: $model.draft.title.singleLineText, axis: .horizontal).singleLinePaste()
|
|
.lineLimit(1)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
if library.conflictingSkillIDs.contains(model.original.id) {
|
|
TextField("Name", text: $model.draft.name.singleLineText).singleLinePaste()
|
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
|
}
|
|
LabeledContent("Name") {
|
|
Text(
|
|
model.draft.effectiveName.isEmpty
|
|
? String(localized: "Derived from the title") : model.draft.effectiveName
|
|
)
|
|
.font(.caption.monospaced()).textSelection(.enabled)
|
|
}
|
|
Toggle(
|
|
"Enabled",
|
|
isOn: Binding(
|
|
get: { model.draft.isEnabled && model.draft.isComplete },
|
|
set: { model.draft.isEnabled = $0 }
|
|
)
|
|
).disabled(!model.draft.isComplete)
|
|
if let problem = AgentSkillNaming.problem(with: model.draft.effectiveName),
|
|
!model.draft.title.isEmpty
|
|
{
|
|
Text(problem.message).font(.caption).foregroundStyle(.orange)
|
|
} else if library.hasNameConflict(model.draft.effectiveName, excluding: model.draft.id) {
|
|
Text("Another skill already uses this name.").foregroundStyle(.orange)
|
|
}
|
|
if !model.draft.importedFrom.isEmpty {
|
|
LabeledContent("Imported From", value: model.draft.importedFrom)
|
|
}
|
|
}
|
|
Section("Description") {
|
|
TextEditor(text: $model.draft.summary).frame(minHeight: 90)
|
|
Text(
|
|
"What this does and when to use it. This is the only part the assistant sees until it loads the skill, so it decides whether the skill is used at all."
|
|
)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Text("\(model.draft.summary.count)/\(AgentSkill.maximumSummaryCharacters)").font(.caption)
|
|
}
|
|
Section("Instructions") {
|
|
TextEditor(text: $model.draft.body).font(.body.monospaced()).frame(minHeight: 240)
|
|
Text("Markdown. Returned whole when the assistant loads this skill.").font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
if model.draft.body.utf8.count > AgentSkill.maximumBodyBytes {
|
|
Text("Longer than \(DataSizeFormat.bytes(AgentSkill.maximumBodyBytes)).")
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
SkillPackageEditor(package: $model.draft.package, readResource: SkillImporter.readResource)
|
|
if let error { Section { Text(error).foregroundStyle(.red) } }
|
|
}
|
|
)
|
|
.fileExporter(
|
|
isPresented: $exporting, document: try? SkillPackageDocument(skill: model.draft),
|
|
contentType: .folder, defaultFilename: model.draft.effectiveName
|
|
) { result in
|
|
if case .failure(let error) = result { self.error = error.localizedDescription }
|
|
}
|
|
}
|
|
|
|
private var toolbarActions: [MobileEditorToolbarAction] {
|
|
var actions = [
|
|
MobileEditorToolbarAction(
|
|
id: "export", title: "Export", systemImage: "square.and.arrow.up",
|
|
perform: { exporting = true })
|
|
]
|
|
if library.skill(model.original.id) != nil {
|
|
actions.append(
|
|
MobileEditorToolbarAction(
|
|
id: "delete", title: "Delete", systemImage: "trash", role: .destructive,
|
|
startsGroup: true, perform: delete))
|
|
}
|
|
return actions
|
|
}
|
|
|
|
private func delete() {
|
|
library.delete(id: model.original.id)
|
|
if let error = library.storageError { self.error = error } else { dismiss() }
|
|
}
|
|
}
|