300 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|