Files

300 lines
12 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import SwiftUI
import TermindApplication
import UniformTypeIdentifiers
public struct MemorySettingsSection: View {
/// A section embeds its actions; a page owns its form and navigation toolbar.
public enum Layout { case section, page }
@Bindable private var memory: MemoryModel
private let ai: AIConfigurationModel
private let layout: Layout
@State private var confirmDestroy = false
@State private var editing: AgentMemoryTarget?
@State private var draft = ""
@State private var exporting = false
@State private var exportError: String?
public init(memory: MemoryModel, ai: AIConfigurationModel, layout: Layout = .section) {
self.memory = memory
self.ai = ai
self.layout = layout
}
@ViewBuilder private var destroyActions: some View {
Button(AppStrings.Settings.destroy.swiftUIKey, role: .destructive) { Task { await memory.destroy() } }
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
}
public var body: some View {
Group {
if layout == .page {
Form { section }
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
if memory.isEnabled {
exportButton
rebuildButton
Divider()
}
destroyButton
} label: {
Image(systemName: "ellipsis").accessibilityLabel(Text(AppStrings.Common.more))
}
}
}
} else {
section
}
}
.task { await memory.refresh() }
#if os(iOS)
.operationConfirmation(
.init(AppStrings.Settings.destroyMemoryConfirmation), isPresented: $confirmDestroy,
placement: .centered,
actions: [
.init(AppStrings.Settings.destroy, role: .destructive) { Task { await memory.destroy() } },
.init(AppStrings.Common.cancel, role: .cancel) {},
],
message: .init(AppStrings.Settings.hermesDestroyDescription)
)
#else
.confirmationDialog(
AppStrings.Settings.destroyMemoryConfirmation, isPresented: $confirmDestroy, titleVisibility: .visible
) {
destroyActions
} message: {
Text(AppStrings.Settings.hermesDestroyDescription)
}
#endif
.sheet(isPresented: Binding(get: { editing != nil }, set: { if !$0 { editing = nil } })) {
NavigationStack {
Form {
TextEditor(text: $draft).autohidingScrollers().frame(minHeight: 220)
if let error = memory.error { Text(error).foregroundStyle(.red) }
if editing != nil {
let usage = MemoryDateAnnotations.draftUsage(draft)
Text(
AppStrings.Settings.hermesMemoryUsage(
entries: usage.entries, characters: usage.characters)
).monospacedDigit()
}
}
.navigationTitle(editing == .memory ? "Memory Notes" : "User Profile")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(AppStrings.Common.cancel.swiftUIKey, systemImage: "xmark") { editing = nil }
}
ToolbarItem(placement: .confirmationAction) {
Button(AppStrings.Common.save.swiftUIKey, systemImage: "checkmark") {
guard let target = editing else { return }
Task {
await memory.edit(target, text: draft)
if memory.error == nil { editing = nil }
}
}
.disabled(memory.isBusy)
#if os(iOS)
.tint(.accentColor)
#endif
}
}
#if os(iOS)
.labelStyle(.iconOnly)
#else
.labelStyle(.titleOnly)
#endif
}.frame(minWidth: 320, minHeight: 360)
}
.fileExporter(
isPresented: $exporting,
document: KnowledgeFileDocument(
data: Data(MemoryDateAnnotations.document(memory.state, references: memory.referenceDates).utf8)),
contentType: .plainText, defaultFilename: "Memory.md"
) { result in
if case .failure(let error) = result { exportError = error.localizedDescription }
}
}
private var section: some View {
Section {
Toggle(
AppStrings.Common.enabled.swiftUIKey,
isOn: Binding(
get: { memory.isEnabled },
set: { enabled in
Task { await memory.setEnabled(enabled) }
})
).disabled(memory.isBusy)
if memory.isEnabled {
Toggle("Automatic Learning", isOn: $memory.reviewEnabled)
Picker("Learning Model", selection: $memory.reviewModel) {
Text("Same as Chat").tag(AIModelRef?.none)
ForEach(ai.enabledModelProviders) { provider in
Section(provider.name) {
ForEach(provider.chatModels) { model in
Text(model.label).tag(
AIModelRef?.some(.init(modelProviderID: provider.id, modelID: model.id)))
}
}
}
}
memoryRows
}
if let error = exportError ?? memory.error {
Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled)
}
if layout == .section {
HStack(spacing: 8) {
if memory.isEnabled {
exportButton
rebuildButton
}
Spacer(minLength: 16)
destroyButton
}
.buttonStyle(.bordered)
.controlSize(.small)
.labelStyle(.titleOnly)
}
if memory.isEnabled && memory.isIndexing {
ProgressView("Indexing History")
#if os(macOS)
.controlSize(.small)
#endif
}
if memory.isBusy { ProgressView() }
} header: {
if layout == .section { Text(AppStrings.Settings.hermesMemory) }
}
}
private var memoryRows: some View {
ForEach(AgentMemoryTarget.allCases, id: \.self) { target in
let usage = memory.state.usage(target)
let count = memory.state.entries.filter { $0.target == target }.count
let title = target == .memory ? AppStrings.Settings.memoryNotes : AppStrings.Settings.userProfile
#if os(macOS)
FormNavigationAction(
title: title,
value: AppStrings.Settings.hermesMemoryUsage(entries: count, characters: usage)
) {
beginEditing(target)
}
#else
Button {
beginEditing(target)
} label: {
HStack {
Text(title)
Spacer()
Text(AppStrings.Settings.hermesMemoryUsage(entries: count, characters: usage))
.monospacedDigit().foregroundStyle(.secondary)
}
.contentShape(.rect)
}
#endif
}
}
private func beginEditing(_ target: AgentMemoryTarget) {
draft = MemoryDateAnnotations.text(for: target, in: memory.state, references: memory.referenceDates)
editing = target
}
private var exportButton: some View {
Button(AppStrings.Common.export.swiftUIKey, systemImage: "square.and.arrow.up") {
exporting = true
}
}
private var rebuildButton: some View {
Button("Rebuild", systemImage: "arrow.clockwise") {
Task { await memory.rebuildHistoryIndex() }
}
.disabled(memory.isIndexing)
}
private var destroyButton: some View {
Button("Destroy", systemImage: "trash", role: .destructive) { confirmDestroy = true }.disabled(memory.isBusy)
}
}
public struct KnowledgeFileDocument: FileDocument {
public static var readableContentTypes: [UTType] { [.data] }
public var data: Data
public init(data: Data) { self.data = data }
public init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: data)
}
}
public struct SkillPackageDocument: FileDocument {
public static var readableContentTypes: [UTType] { [.folder] }
private var files: [String: Data]
public init(skill: AgentSkill) throws {
try skill.package?.validate()
files = skill.package?.files ?? [:]
files["SKILL.md"] = Data(AgentSkillDocument.render(skill).utf8)
}
public init(configuration: ReadConfiguration) throws {
var contents: [String: Data] = [:]
func read(_ wrapper: FileWrapper, prefix: String) throws {
if let children = wrapper.fileWrappers {
for (name, child) in children {
let path = prefix.isEmpty ? name : prefix + "/" + name
try AgentSkillPackage.validatePath(path)
try read(child, prefix: path)
}
} else if let bytes = wrapper.regularFileContents {
guard contents.count < AgentSkillPackage.maximumFiles + 1,
bytes.count <= AgentSkillPackage.maximumFileBytes
else { throw AgentSkillImportError.tooLarge }
contents[prefix] = bytes
} else {
throw AgentSkillImportError.notReadable
}
}
try read(configuration.file, prefix: "")
guard contents.values.reduce(0, { $0 + $1.count }) <= AgentSkillPackage.maximumBytes else {
throw AgentSkillImportError.tooLarge
}
files = contents
}
private func makeWrapper() throws -> FileWrapper {
let root = FileWrapper(directoryWithFileWrappers: [:])
for (path, bytes) in files.sorted(by: { $0.key < $1.key }) {
try AgentSkillPackage.validatePath(path)
let parts = path.components(separatedBy: "/")
var directory = root
for part in parts.dropLast() {
if let existing = directory.fileWrappers?[part] {
guard existing.isDirectory else { throw AgentSkillImportError.notReadable }
directory = existing
} else {
let child = FileWrapper(directoryWithFileWrappers: [:])
child.preferredFilename = part
directory.addFileWrapper(child)
directory = child
}
}
guard directory.fileWrappers?[parts.last!] == nil else { throw AgentSkillImportError.notReadable }
let file = FileWrapper(regularFileWithContents: bytes)
file.preferredFilename = parts.last
directory.addFileWrapper(file)
}
return root
}
public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { try makeWrapper() }
public func write(to url: URL) throws {
try makeWrapper().write(to: url, options: .atomic, originalContentsURL: nil)
}
}