Files
Termind/TFI/App/MobileAssistantModel.swift
T
2026-09-11 21:07:44 +08:00

215 lines
8.5 KiB
Swift

import AgentKit
import Foundation
import Observation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindRemoteInfrastructure
/// Application-owned runs, with navigation and drafts independent of each run.
@MainActor
@Observable
final class MobileAssistantModel {
let store = AssistantConversationStore()
let library: AssistantLibraryModel
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 var suspensionTask: Task<Void, Never>?
private var loaded = false
private(set) var isSuspending = false
var error: String?
var conversation: ConversationModel { library.selectedConversation ?? library.draftConversation }
var isRunning: Bool { store.conversations.contains { $0.isStreaming } }
init(
configuration: AIConfigurationModel, repository: any AgentRunPersisting,
hosts: HostLibraryModel, metrics: any MetricsSnapshotReading, skills: SkillLibraryModel,
authorize: @escaping @MainActor () -> Bool,
speech: SpeechTranscriptionCoordinator? = nil
) {
self.authorize = authorize
self.configuration = configuration
self.repository = repository
self.skills = skills
library = AssistantLibraryModel(store: store)
operations = AssistantConversationOperations(repository: repository)
let audio = IOSSpeechAudioInput()
let coordinator =
speech ?? SpeechTranscriptionCoordinator(service: AppleSpeechTranscriptionService(audioInput: audio))
self.speech = coordinator
audio.onInterruption = { [weak coordinator] in coordinator?.cancelAll() }
let resolver = AgentHostResolver(
store: hosts,
discoverHostKey: { id, host, port, proxy, jumps in
try await SSHSessionActor.discoverHostKey(
hostID: id, host: host, port: port, proxy: proxy, jumps: jumps)
})
let connections = AgentConnectionPool(
configuration: { try await resolver.configuration(for: $0) },
routeFingerprint: { try await resolver.routeFingerprint(for: $0) },
openExec: { configuration in
let session = SSHExecSession()
try await session.connect(using: configuration)
return session
},
openSFTP: { configuration in
let client = SFTPClient()
try await client.connect(using: configuration)
return client
})
self.connections = connections
let authentication = AgentSSHAuthenticationRegistry(resolver: resolver)
let background = AgentBackgroundCommands()
self.background = background
let titles = ConversationTitleController(ai: configuration)
execution = AssistantExecutionController(
ai: configuration, skills: skills, repository: repository,
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) })
configure(conversation)
}
func load() async {
guard !loaded else { return }
loaded = true
do {
library.restore(try await repository.loadConversations())
store.conversations.forEach(configure)
} catch {
loaded = false
self.error = error.localizedDescription
}
}
private func configure(_ conversation: ConversationModel) { conversation.attachmentLoader = .live }
func select(_ conversation: ConversationModel) {
speech.cancelAll()
library.select(conversation.id)
configure(conversation)
}
func newConversation() {
speech.cancelAll()
_ = library.selectNewConversation()
configure(conversation)
}
func send() {
let conversation = conversation
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 }
_ = library.startConversation(conversation)
execution.sendAgentDraft(conversation)
}
func stop() { for conversation in store.conversations { conversation.stop() } }
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 }
func selectModel(_ value: AIModelRef?) {
conversation.model = value
if let value { configuration.select(value) }
}
func archive(_ conversation: ConversationModel, archived: Bool) async {
await perform {
try await operations.setArchived(archived, conversation: conversation)
if archived && conversation.isArchived && self.conversation.id == conversation.id { newConversation() }
}
}
func rename(_ conversation: ConversationModel, title: String) async {
await perform { try await operations.rename(conversation, title: title) }
}
func move(_ conversation: ConversationModel, pinned: Bool, before: UUID? = nil) async {
await perform { try await operations.move(conversation, pinned: pinned, before: before, library: library) }
}
func delete(_ conversation: ConversationModel) 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)
configure(self.conversation)
if let cleanupError = result.cleanupError { error = cleanupError }
}
}
func fork(_ conversation: ConversationModel, at messageID: UUID) 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) 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)
}
func implement(_ card: AgentPlanCard, in conversation: ConversationModel) {
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)
send()
}
private func perform(_ operation: () async throws -> Void) async {
do { try await operation() } catch { self.error = error.localizedDescription }
}
}