Files
Termind/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift
T

382 lines
16 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
/// The app's conversations, and the single instance of each.
///
/// Conversations are app state, not window state: a run belongs to a
/// conversation, and a conversation opened in two windows is one conversation.
/// Building a `ConversationModel` per window meant a reply streaming in one
/// window was invisible in the other, and two windows could start concurrent
/// runs against the same stored history.
@Observable
public final class AssistantConversationStore {
public private(set) var conversations: [ConversationModel] = []
public init(conversations: [ConversationModel] = []) {
self.conversations = conversations
}
public func conversation(id: ConversationModel.ID) -> ConversationModel? {
conversations.first { $0.id == id }
}
public func insert(_ conversation: ConversationModel) {
guard !conversations.contains(where: { $0.id == conversation.id }) else { return }
conversations.insert(conversation, at: 0)
}
/// Forks use the same lowest-free numbering as duplicated terminal workspaces:
/// `Title (1)`, `Title (2)`, and a vacated number can be reused. Forking a
/// fork continues the original sequence instead of producing `(1) (1)`.
public func nextForkTitle(for source: ConversationModel) -> String {
let sourceTitle = source.derivedTitle
let root = forkRoot(for: sourceTitle, sourceID: source.id)
let taken = Set(conversations.map(\.derivedTitle))
var index = 1
while taken.contains(ConversationModel.numberedForkTitle(base: root, index: index)) {
index += 1
}
return ConversationModel.numberedForkTitle(base: root, index: index)
}
private func forkRoot(for title: String, sourceID: UUID) -> String {
guard
let range = title.range(
of: #" \([0-9]+\)$"#, options: .regularExpression
)
else { return title }
let candidate = String(title[..<range.lowerBound])
let belongsToExistingSequence = conversations.contains { conversation in
guard conversation.id != sourceID else { return false }
let other = conversation.derivedTitle
if other == candidate { return true }
guard
let suffix = other.range(
of: #" \([0-9]+\)$"#, options: .regularExpression
)
else { return false }
return other[..<suffix.lowerBound] == candidate
}
return belongsToExistingSequence ? candidate : title
}
/// Removes one conversation and hands back where it was, so each window can
/// move its own selection to whatever took its place.
@discardableResult
public func remove(_ id: ConversationModel.ID) -> Int? {
guard let index = conversations.firstIndex(where: { $0.id == id }) else { return nil }
conversations[index].stop()
conversations[index].notificationEvent?(.removed)
conversations.remove(at: index)
return index
}
public func restore(_ stored: [StoredAgentConversation], modelSelections: [UUID: AIModelRef] = [:]) {
let existingIDs = Set(conversations.map(\.id))
let restored = stored.filter { !existingIDs.contains($0.id) }.map { value in
let conversation = ConversationModel(
id: value.id,
messages: value.messages.compactMap { message in
guard !message.isCompaction else { return nil }
return switch message.role {
case .user:
ChatMessage(
id: message.id, role: .user,
text: message.authoredText ?? ContextChip.visiblePrompt(in: message.text),
contextChips: ContextChip.visibleReferences(
in: message.text, images: message.images,
contextAttachments: message.contextAttachments
),
images: message.images,
origin: message.origin,
createdAt: message.createdAt
)
case .assistant:
ChatMessage(
id: message.id, role: .assistant, text: message.text,
reasoning: message.reasoning,
createdAt: message.createdAt
)
case .tool: nil
}
},
// Tool rows are dropped from the visible list because they are
// drawn as cards, but the model still needs them next turn.
transcript: value.modelTranscript ?? value.messages,
runState: value.lastRunState,
messageRunIDs: value.messageRunIDs,
runs: value.runs,
compactionRecords: value.compactions,
createdAt: value.metadata.createdAt,
updatedAt: value.metadata.updatedAt,
sortIndex: value.metadata.sortIndex
)
// Empty is "never summarized", not a name of nothing — `derivedTitle`
// has to be able to tell those apart to fall back.
conversation.title = value.title.isEmpty ? nil : value.title
conversation.model = modelSelections[value.id]
conversation.customTitle =
value.metadata.customTitle.isEmpty
? nil : value.metadata.customTitle
conversation.isPinned = value.metadata.isPinned
conversation.archivedAt = value.metadata.archivedAt
conversation.toolCards = value.toolCards.map { card in
let state: AgentToolCard.State
if card.result?.isInterrupted == true {
state = .interrupted
} else if card.result?.isError == true {
state = .failed
} else if card.result != nil || card.state == "finished" {
state = .completed
}
// Not `.running`, which is what this used to restore to. Every
// unfinished run is marked interrupted at launch, so a card
// that was mid-flight when the process died is not running
// now and never will be — it came back as a spinner that
// nothing would ever stop.
else if card.state == "started" {
state = .interrupted
} else {
state = .proposed
}
return AgentToolCard(
invocation: card.invocation, descriptor: card.descriptor,
state: state, result: card.result, review: card.review
)
}
return conversation
}
conversations = restored + conversations
}
}
/// Window-local navigation over the app's conversations.
///
/// The list is shared; only the selection is per window, which is what lets two
/// windows show different chats from the same set without duplicating any of
/// them.
@Observable
public final class AssistantLibraryModel {
private var store: AssistantConversationStore
public var selectedConversationID: ConversationModel.ID?
public var modelSelections: AssistantModelSelectionController? {
didSet { modelSelections?.seed(draftConversation) }
}
/// Window-local scratch space for the Assistant destination's New Chat surface.
/// It is promoted into the shared history only after its first real send,
/// so merely entering Assistant never creates an empty list row.
public private(set) var draftConversation = ConversationModel()
public init(store: AssistantConversationStore = AssistantConversationStore()) {
self.store = store
selectedConversationID = nil
}
public var conversations: [ConversationModel] { store.conversations }
public var pinnedConversations: [ConversationModel] {
conversations.filter { !$0.isArchived && $0.isPinned }
.sorted(by: Self.manualOrder)
}
public var activeConversations: [ConversationModel] {
conversations.filter { !$0.isArchived && !$0.isPinned }
.sorted(by: Self.manualOrder)
}
public var archivedConversations: [ConversationModel] {
conversations.filter(\.isArchived).sorted { $0.updatedAt > $1.updatedAt }
}
private static func manualOrder(_ lhs: ConversationModel, _ rhs: ConversationModel) -> Bool {
if lhs.sortIndex != rhs.sortIndex { return lhs.sortIndex < rhs.sortIndex }
if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt }
return lhs.id.uuidString < rhs.id.uuidString
}
/// Produces one complete, normalized order for both live groups. Removing
/// the source before resolving `before` makes the target mean the same thing
/// whether the conversation crosses the Pinned boundary or stays in place.
public func orderUpdates(
moving id: ConversationModel.ID,
toPinned: Bool,
before nextID: ConversationModel.ID?
) -> [StoredAgentConversationOrder]? {
guard let source = conversation(id: id), !source.isArchived else { return nil }
var pinned = pinnedConversations.filter { $0.id != id }
var chats = activeConversations.filter { $0.id != id }
if toPinned {
let index =
nextID.flatMap { next in pinned.firstIndex { $0.id == next } }
?? pinned.endIndex
pinned.insert(source, at: index)
} else {
let index =
nextID.flatMap { next in chats.firstIndex { $0.id == next } }
?? chats.endIndex
chats.insert(source, at: index)
}
return pinned.enumerated().map {
StoredAgentConversationOrder(id: $0.element.id, isPinned: true, sortIndex: Double($0.offset))
}
+ chats.enumerated().map {
StoredAgentConversationOrder(id: $0.element.id, isPinned: false, sortIndex: Double($0.offset))
}
}
public func applyOrder(_ updates: [StoredAgentConversationOrder]) {
for update in updates {
guard let conversation = conversation(id: update.id) else { continue }
conversation.isPinned = update.isPinned
conversation.sortIndex = update.sortIndex
}
}
/// Joins the app-wide store, carrying over anything this window made before
/// it was registered. Windows are registered on appear, so in practice that
/// is nothing — but silently dropping a conversation would be worse than the
/// two lines it costs to keep it.
public func adopt(_ shared: AssistantConversationStore) {
guard store !== shared else { return }
for conversation in store.conversations.reversed() { shared.insert(conversation) }
store = shared
if let selectedConversationID,
shared.conversation(id: selectedConversationID) == nil
{
self.selectedConversationID = nil
}
}
public var selectedConversation: ConversationModel? {
guard let selectedConversationID else { return nil }
return conversation(id: selectedConversationID)
}
public func conversation(id: ConversationModel.ID) -> ConversationModel? {
store.conversation(id: id)
}
@discardableResult
public func createConversation() -> ConversationModel {
let conversation = newConversation()
store.insert(conversation)
selectedConversationID = conversation.id
return conversation
}
public func insertAndSelect(_ conversation: ConversationModel) {
store.insert(conversation)
selectedConversationID = conversation.id
}
/// Shows New Chat without manufacturing an empty history item. Keeping one
/// scratch model also preserves an unfinished draft while the user briefly
/// opens an older conversation and comes back.
@discardableResult
public func selectNewConversation() -> ConversationModel {
selectedConversationID = nil
return draftConversation
}
/// Starts a deliberately fresh New Chat surface. Platform navigation can
/// use this for an explicit New action, while `selectNewConversation()`
/// continues to preserve a draft when merely navigating away and back.
@discardableResult
public func resetDraftConversation() -> ConversationModel {
selectedConversationID = nil
draftConversation = newConversation()
return draftConversation
}
public func canPromote(_ conversation: ConversationModel) -> Bool {
!conversations.contains(where: { $0.id == conversation.id })
}
/// Publishes a conversation only after its first user turn and immutable run
/// capture have already succeeded.
@discardableResult
public func promoteCapturedConversation(_ conversation: ConversationModel) -> Bool {
guard canPromote(conversation), !conversation.transcript.isEmpty else { return false }
store.insert(conversation)
selectedConversationID = conversation.id
return true
}
@discardableResult
public func promoteCapturedDraftConversation(_ conversation: ConversationModel) -> Bool {
guard conversation === draftConversation,
promoteCapturedConversation(conversation)
else { return false }
draftConversation = newConversation()
return true
}
private func newConversation() -> ConversationModel {
let conversation = ConversationModel()
modelSelections?.seed(conversation)
return conversation
}
public func select(_ id: ConversationModel.ID) {
guard conversations.contains(where: { $0.id == id }) else { return }
selectedConversationID = id
}
public func delete(_ id: ConversationModel.ID) {
store.remove(id)
guard selectedConversationID == id else { return }
// Archived chats no longer live in the ordinary navigator. Deleting a
// live chat must therefore never select a hidden archived row as its
// successor; return to the most relevant visible chat, or New Chat.
selectedConversationID = (pinnedConversations + activeConversations).first?.id
}
public func restore(_ stored: [StoredAgentConversation], modelSelections: [UUID: AIModelRef] = [:]) {
store.restore(stored, modelSelections: modelSelections)
if let selectedConversationID,
conversation(id: selectedConversationID) == nil
{
self.selectedConversationID = nil
}
}
// MARK: - Search
/// Which half of the list a search runs over. Active and archived are
/// separate destinations, so a match in one must never surface in the other
/// — finding an archived chat while looking at live ones, and then opening
/// it, would silently take the user somewhere they did not navigate.
public nonisolated enum SearchScope: Sendable {
case active
case archived
}
/// Titles and visible message bodies, and deliberately nothing else.
///
/// `ChatMessage.reasoning`, the tool cards and the raw `transcript` are all
/// excluded: they hold hidden thinking, unredacted tool output and whatever
/// a command printed, none of which the user chose to put on screen. A
/// search field is not a way to read them.
public func search(_ query: String, scope: SearchScope) -> [ConversationModel] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
let candidates: [ConversationModel] =
switch scope {
case .active: pinnedConversations + activeConversations
case .archived: archivedConversations
}
guard !trimmed.isEmpty else { return candidates }
return candidates.filter { $0.matches(trimmed) }
}
}
extension ConversationModel {
/// See `AssistantLibraryModel.search(_:scope:)` for what is deliberately
/// not searched here.
public func matches(_ query: String) -> Bool {
if derivedTitle.localizedCaseInsensitiveContains(query) { return true }
return messages.contains { $0.text.localizedCaseInsensitiveContains(query) }
}
}