Files
Termind/TFI/Application/Agent/MobileAssistantModel.swift
T
Kaede Akino 8d591dd0f6 Add global Hermes memory and skill package tools
Add opt-in GRDB memory with frozen conversation snapshots, background learning, session search, and a user-only Destroy action on both platforms. Persist and synchronize complete skill packages with the new AgentKit management tools.

Pin signed AgentKit 0.12.3 and GRDB 7.11.1. Add the V18 package migration, regression coverage, bilingual UI snapshots, and architecture records.

Validation: AgentKit and iOS suites, focused macOS skills/memory/architecture/localization tests, Swift formatting, and Chinese localization checks passed. Full macOS runs exposed intermittent existing waveform-rendering and metrics-timing failures.
2026-09-17 07:54:04 +08:00

307 lines
12 KiB
Swift

import AgentKit
import Foundation
import Observation
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
/// Application-owned Assistant runs and services. Scene-local navigation lives in
/// `MobileAssistantSceneModel`; only the conversation store is shared here.
@MainActor
@Observable
final class MobileAssistantModel {
let store = AssistantConversationStore()
let speech: SpeechTranscriptionCoordinator
let configuration: AIConfigurationModel
let skills: SkillLibraryModel
private let authorize: @MainActor () -> Bool
private let repository: any AgentRunPersisting
private let operations: AssistantConversationOperations
private let execution: AssistantExecutionController
private let connections: AgentConnectionPool
private let background: AgentBackgroundCommands
private let attachmentLoader: ContextAttachmentLoading
private var loadTask: Task<Void, Never>?
private var suspensionTask: Task<Void, Never>?
private var loaded = false
private(set) var isSuspending = false
var error: String?
var isRunning: Bool { store.conversations.contains { $0.isStreaming } }
func disconnectHost(_ id: UUID) {
Task { await connections.disconnect(hostID: id) }
}
func makeSceneModel() -> MobileAssistantSceneModel {
MobileAssistantSceneModel(runtime: self, library: AssistantLibraryModel(store: store))
}
init(
configuration: AIConfigurationModel, repository: any AgentRunPersisting, memory: MemoryModel? = nil,
hostServices: AgentHostServices, metrics: any MetricsSnapshotReading, skills: SkillLibraryModel,
authorize: @escaping @MainActor () -> Bool,
speech: SpeechTranscriptionCoordinator,
attachmentLoader: ContextAttachmentLoading
) {
self.authorize = authorize
self.configuration = configuration
self.repository = repository
self.skills = skills
self.speech = speech
self.attachmentLoader = attachmentLoader
operations = AssistantConversationOperations(repository: repository)
let resolver = hostServices.resolver
let connections = hostServices.connections
let authentication = hostServices.authentication
let background = hostServices.background
self.connections = connections
self.background = background
let titles = ConversationTitleController(ai: configuration)
execution = AssistantExecutionController(
ai: configuration, skills: skills, repository: repository, memory: memory,
authorize: authorize,
makeRegistry: { conversation, model, provider, catalog, channel, _ in
AssistantToolRegistry.make(
conversation: conversation, model: model, provider: provider,
skills: catalog, channel: channel, ai: configuration, hosts: resolver, metrics: metrics,
connections: connections, authentication: authentication, background: background)
}, title: { try await titles.summarize($0) })
}
func load() async {
if loaded { return }
if let loadTask {
await loadTask.value
return
}
let loadTask = Task { [weak self] in
guard let self else { return }
await loadOnce()
}
self.loadTask = loadTask
await loadTask.value
self.loadTask = nil
}
private func loadOnce() async {
do {
store.restore(try await repository.loadConversations())
store.conversations.forEach(configure)
loaded = true
} catch {
self.error = error.localizedDescription
}
}
fileprivate func configure(_ conversation: ConversationModel) { conversation.attachmentLoader = attachmentLoader }
fileprivate func select(_ conversation: ConversationModel, in library: AssistantLibraryModel) {
speech.cancelAll()
library.select(conversation.id)
configure(conversation)
}
fileprivate func newConversation(in library: AssistantLibraryModel) {
speech.cancelAll()
_ = library.resetDraftConversation()
configure(library.draftConversation)
}
fileprivate func send(in library: AssistantLibraryModel) {
let conversation = library.selectedConversation ?? library.draftConversation
guard !isSuspending, !conversation.isArchived, !conversation.isHistoryChanging,
!conversation.isCompacting, conversation.composer.pendingAttachments.isEmpty, authorize()
else { return }
if conversation.isStreaming {
conversation.submitSteeringDraft()
return
}
guard conversation.canSendDraft else { return }
if library.selectedConversation == nil {
guard let started = library.startDraftConversation() else { return }
configure(library.draftConversation)
execution.startAgentRun(started)
} else {
execution.sendAgentDraft(conversation)
}
}
func suspendRecording() { speech.cancelAll() }
func expireBackground() {
guard !isSuspending else { return }
isSuspending = true
execution.cancelPendingRuns()
speech.cancelAll()
let conversations = store.conversations
suspensionTask = Task {
for conversation in conversations { await conversation.stopAndWait() }
await background.closeAll()
await connections.closeAll()
isSuspending = false
suspensionTask = nil
}
}
func waitForSuspension() async { await suspensionTask?.value }
fileprivate func selectModel(_ value: AIModelRef, for conversation: ConversationModel) {
guard let resolved = configuration.lookup(value) else { return }
conversation.model = value
configuration.select(value)
conversation.reasoningEffort = ModelCapabilityResolver.reasoning(
model: resolved.model, provider: resolved.provider
).clamp(conversation.reasoningEffort)
}
fileprivate func archive(
_ conversation: ConversationModel, archived: Bool, in library: AssistantLibraryModel
) async {
await perform {
try await operations.setArchived(archived, conversation: conversation)
let selected = library.selectedConversation ?? library.draftConversation
if archived && conversation.isArchived && selected.id == conversation.id {
newConversation(in: library)
}
}
}
func rename(_ conversation: ConversationModel, title: String) async {
await perform { try await operations.rename(conversation, title: title) }
}
fileprivate func move(
_ conversation: ConversationModel, pinned: Bool, before: UUID? = nil,
in library: AssistantLibraryModel
) async {
await perform { try await operations.move(conversation, pinned: pinned, before: before, library: library) }
}
fileprivate func delete(_ conversation: ConversationModel, from library: AssistantLibraryModel) async {
guard !conversation.isStreaming, !conversation.isCompacting else { return }
await perform {
let result = try await operations.delete(conversation)
guard result.deleted else { return }
library.delete(conversation.id)
if library.selectedConversation == nil { _ = library.resetDraftConversation() }
configure(library.selectedConversation ?? library.draftConversation)
if let cleanupError = result.cleanupError { error = cleanupError }
}
}
fileprivate func fork(
_ conversation: ConversationModel, at messageID: UUID, in library: AssistantLibraryModel
) async {
await perform {
if let fork = try await operations.fork(
conversation, at: messageID, title: store.nextForkTitle(for: conversation))
{
configure(fork)
library.insertAndSelect(fork)
}
}
}
func edit(_ conversation: ConversationModel, messageID: UUID, text: String) async {
await perform {
guard
let prompt = try await operations.edit(
conversation, messageID: messageID, text: text,
contextSnapshot: execution.contextSnapshot(
for: conversation, context: .none, includesTerminalSuggestion: false))
else { return }
execution.startAgentRun(
conversation, promptID: prompt.id, prompt: prompt.text,
authoredPrompt: prompt.authoredText, promptImages: prompt.images,
promptContextAttachments: prompt.contextAttachments)
}
}
func compact(_ conversation: ConversationModel, instructions: String = "") async {
_ = await execution.compact(conversation, instructions: instructions)
}
fileprivate func implement(
_ card: AgentPlanCard, in conversation: ConversationModel, library: AssistantLibraryModel
) {
guard !conversation.isArchived, !conversation.isStreaming else { return }
conversation.resolvePlan(card.id, as: .implemented)
conversation.isPlanning = false
conversation.composer.draft = String(localized: "Implement this plan.")
select(conversation, in: library)
send(in: library)
}
private func perform(_ operation: () async throws -> Void) async {
do { try await operation() } catch { self.error = error.localizedDescription }
}
}
/// Scene-local Assistant navigation over the app-wide conversation store and run services.
///
/// The runtime owns work that must survive navigation. The library owns only the selected
/// conversation and the New Chat draft, so two iPad windows can navigate independently
/// without duplicating a running conversation.
@MainActor
@Observable
final class MobileAssistantSceneModel {
private let runtime: MobileAssistantModel
let library: AssistantLibraryModel
init(runtime: MobileAssistantModel, library: AssistantLibraryModel? = nil) {
self.runtime = runtime
self.library = library ?? AssistantLibraryModel(store: runtime.store)
runtime.configure(self.library.draftConversation)
}
var conversation: ConversationModel { library.selectedConversation ?? library.draftConversation }
var speech: SpeechTranscriptionCoordinator { runtime.speech }
var configuration: AIConfigurationModel { runtime.configuration }
var skills: SkillLibraryModel { runtime.skills }
var isRunning: Bool { runtime.isRunning }
var isSuspending: Bool { runtime.isSuspending }
var error: String? {
get { runtime.error }
set { runtime.error = newValue }
}
func load() async { await runtime.load() }
func select(_ conversation: ConversationModel) { runtime.select(conversation, in: library) }
func newConversation() { runtime.newConversation(in: library) }
func send() { runtime.send(in: library) }
func selectModel(_ value: AIModelRef) { runtime.selectModel(value, for: conversation) }
func archive(_ conversation: ConversationModel, archived: Bool) async {
await runtime.archive(conversation, archived: archived, in: library)
}
func rename(_ conversation: ConversationModel, title: String) async {
await runtime.rename(conversation, title: title)
}
func move(_ conversation: ConversationModel, pinned: Bool, before: UUID? = nil) async {
await runtime.move(conversation, pinned: pinned, before: before, in: library)
}
func delete(_ conversation: ConversationModel) async {
await runtime.delete(conversation, from: library)
}
func fork(_ conversation: ConversationModel, at messageID: UUID) async {
await runtime.fork(conversation, at: messageID, in: library)
}
func edit(_ conversation: ConversationModel, messageID: UUID, text: String) async {
await runtime.edit(conversation, messageID: messageID, text: text)
}
func compact(_ conversation: ConversationModel, instructions: String = "") async {
await runtime.compact(conversation, instructions: instructions)
}
func implement(_ card: AgentPlanCard, in conversation: ConversationModel) {
runtime.implement(card, in: conversation, library: library)
}
}