import Foundation @MainActor final class ChatStore: ObservableObject { @Published var conversations: [HopeConversation] = [] @Published var workspaces: [HopeWorkspace] = [] @Published var preferences = HopePreferences() @Published var selected: HopeConversation? @Published var messages: [HopeMessage] = [] @Published var runs: [HopeRun] = [] @Published var draft = "" @Published var error: String? @Published var progress: String? @Published var busy = false @Published var searching = false @Published var nextCursor: String? @Published var olderMessagesCursor: String? private var currentQuery = "" private var currentArchived = false private var currentWorkspace: String? @Published private(set) var mutatingConversationIds: Set = [] private(set) var draftGeneration = 0 private var searchGeneration = 0 @Published var approval: (id: String, prompt: String)? @Published var researchAvailable = false @Published var research = false @Published var exportText: String? @Published var exportFile: PrivateChatExport? private var syncCursor = 0 private var syncTask: Task? @Published var model = "" @Published var thinkingLevel = "" @Published var models: [ChatModelChoice] = [] @Published var modelNote: String? let features: ChatFeatureStore @Published var selectedAttachmentIds: Set = [] private var queuedAttachments: [String: ChatAttachment] = [:] var canSendAttachments: Bool { guard !closed else { return false } if selectedAttachmentIds.isEmpty { return true } guard features.enabled.contains("attachments"), selectedAttachmentIds.count <= 8 else { return false } return selectedAttachmentIds.allSatisfy { id in guard let attachment = queuedAttachments[id] ?? features.attachments.first(where: { $0.id == id }) else { return false } return attachmentEligible(attachment) } } private func attachmentEligible(_ attachment: ChatAttachment) -> Bool { let image = ["image/png", "image/jpeg", "image/webp"].contains(attachment.mime) let document = ["text/plain", "text/markdown", "application/pdf"].contains(attachment.mime) return (image && attachment.extractionStatus == "image" && features.enabled.contains("image_input")) || (document && ["ready", "truncated"].contains(attachment.extractionStatus)) } private let api: ChatTransport private var generation = 0 private var stream: Task? private var cursors: [String: Int] = [:] private var drafts: [String: String] = [:] private var pendingKey: (content: String, key: String)? private var closed = false var onUnauthorized: (() async -> Void)? var activeRun: HopeRun? { runs.first(where: \.active) } init(api: ChatTransport) { self.api = api; self.features = ChatFeatureStore(api: api) } deinit { stream?.cancel(); syncTask?.cancel() } private func call(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = [], selectionBound: Bool = true) async throws -> T { let epoch = generation let data = try await api.request(path, method: method, body: body, query: query) try Task.checkCancellation() guard !closed, !selectionBound || generation == epoch else { throw CancellationError() } return try ChatCoding.decoder.decode(T.self, from: data) } private func failure(_ value: Error) async { guard !closed, !(value is CancellationError) else { return } if case ChatFailure.status(let code) = value, code == 401 || code == 403 { await onUnauthorized?(); return } error = value.localizedDescription } func load(query: String = "", archived: Bool = false, workspace: String? = nil, more: Bool = false) async { guard !closed else { return } let sameScope = currentQuery == query && currentArchived == archived && currentWorkspace == workspace currentQuery = query; currentArchived = archived; currentWorkspace = workspace searchGeneration += 1 let search = searchGeneration searching = true defer { if search == searchGeneration { searching = false } } do { var q = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "archived", value: String(archived)), URLQueryItem(name: "limit", value: "50")] if let workspace { q.append(URLQueryItem(name: "workspace_id", value: workspace)) } if more, sameScope, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) } let page: ConversationPage = try await call("api/conversations", query: q, selectionBound: false) guard search == searchGeneration else { return } if more && sameScope { let existing = Set(conversations.map(\.id)); conversations += page.conversations.filter { !existing.contains($0.id) } } else { conversations = page.conversations } nextCursor = page.nextCursor let spaces: WorkspacePage = try await call("api/workspaces", selectionBound: false) guard search == searchGeneration else { return }; workspaces = spaces.workspaces let prefs: PreferencesEnvelope = try await call("api/preferences", selectionBound: false) guard search == searchGeneration else { return }; preferences = prefs.preferences error = nil } catch { await failure(error) } } func open(_ conversation: HopeConversation) async { guard !closed else { return } draftGeneration += 1 if let selected { drafts[selected.id] = draft } generation += 1; stream?.cancel(); stream = nil selected = conversation; messages = []; runs = []; progress = nil; approval = nil; exportText = nil; exportFile = nil draft = drafts[conversation.id] ?? ""; pendingKey = nil; selectedAttachmentIds = []; olderMessagesCursor = nil; error = nil do { let detail: ConversationDetail = try await call("api/conversations/\(conversation.id)") selected = detail.conversation; messages = detail.messages; runs = detail.runs; olderMessagesCursor = detail.nextCursor if let activeRun { replay(activeRun.id) } } catch { await failure(error) } } func loadOlder() async { guard let selected, let cursor = olderMessagesCursor else { return } do { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)", query: [URLQueryItem(name: "cursor", value: cursor)]) let existing = Set(messages.map(\.id)) messages = detail.messages.filter { !existing.contains($0.id) } + messages olderMessagesCursor = detail.nextCursor } catch { await failure(error) } } func create(workspace: String? = nil) async { guard !busy else { return }; busy = true; defer { busy = false } do { var body: [String: Any] = [:]; if let workspace { body["workspace_id"] = workspace } let response: ConversationEnvelope = try await call("api/conversations", method: "POST", body: body) let selectionEpoch = generation await refreshList() guard !closed, generation == selectionEpoch else { busy = false; return } await open(response.conversation) } catch { await failure(error) } } func send() async { guard let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id) else { return } let content = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { return } guard canSendAttachments else { error = "Some attachments are unavailable for the current model. Refresh Files, choose a supported model, or remove those files before sending." return } busy = true; error = nil; defer { busy = false } let payloadIdentity = content + "\n" + selectedAttachmentIds.sorted().joined(separator: ",") + String(research && researchAvailable) if pendingKey?.content != payloadIdentity { pendingKey = (payloadIdentity, UUID().uuidString) } do { let response: SendEnvelope = try await call("api/conversations/\(selected.id)/messages", method: "POST", body: ["content": content, "idempotency_key": pendingKey!.key, "attachment_ids": Array(selectedAttachmentIds).sorted(), "mode": research && researchAvailable ? "research" : "normal"]) draftGeneration += 1 upsert(response.message); upsert(response.run); draft = ""; drafts[selected.id] = ""; pendingKey = nil; selectedAttachmentIds = [] if response.run.active { replay(response.run.id) } else { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)"); messages = detail.messages } } catch { await failure(error) } } private func upsert(_ message: HopeMessage) { guard message.conversationId == selected?.id else { return } if let index = messages.firstIndex(where: { $0.id == message.id }) { messages[index] = message } else { messages.append(message) } } private func upsert(_ run: HopeRun) { guard run.conversationId == selected?.id else { return } if let index = runs.firstIndex(where: { $0.id == run.id }) { runs[index] = run } else { runs.append(run) } } /// Full-message replacement, never concatenate replayed content. func apply(_ event: ChatEvent, run: String) throws { guard event.id > cursors[run, default: 0] else { return } switch event.name { case "message": struct Envelope: Decodable { let message: HopeMessage } upsert(try ChatCoding.decoder.decode(Envelope.self, from: event.data).message) case "run", "done": upsert(try ChatCoding.decoder.decode(RunEnvelope.self, from: event.data).run) case "progress": struct Envelope: Decodable { let message: String } progress = try ChatCoding.decoder.decode(Envelope.self, from: event.data).message case "approval": struct Envelope: Decodable { let approvalId: String; let prompt: String } let value = try ChatCoding.decoder.decode(Envelope.self, from: event.data) approval = (value.approvalId, value.prompt) default: break } cursors[run] = event.id if activeRun == nil { progress = nil; approval = nil } } func resume() { guard !closed, let activeRun else { return }; replay(activeRun.id) } private func replay(_ run: String) { stream?.cancel() let epoch = generation stream = Task { [weak self] in guard let self else { return } do { try await api.events(run: run, after: cursors[run, default: 0]) { [weak self] event in guard let self, self.generation == epoch, !self.closed else { throw CancellationError() } try self.apply(event, run: run) } guard generation == epoch, !closed else { return } if activeRun != nil { error = "Connection interrupted. Resume to replay the latest response." } } catch { guard generation == epoch else { return }; await failure(error) } } } func stop() async { guard let activeRun else { return } do { let response: RunEnvelope = try await call("api/runs/\(activeRun.id)/cancel", method: "POST"); upsert(response.run); stream?.cancel(); progress = nil; approval = nil } catch { await failure(error) } } func decide(_ decision: String) async { guard let activeRun, let approval else { return } do { let result: RunEnvelope = try await call("api/runs/\(activeRun.id)/approval", method: "POST", body: ["approval_id": approval.id, "decision": decision]); upsert(result.run); self.approval = nil; replay(result.run.id) } catch { await failure(error) } } func canMutate(_ conversation: HopeConversation) -> Bool { !closed && !mutatingConversationIds.contains(conversation.id) && (selected?.id != conversation.id || (!busy && activeRun == nil)) } private func refreshList() async { await load(query: currentQuery, archived: currentArchived, workspace: currentWorkspace) } private func updateMetadata(_ value: HopeConversation) { if selected?.id == value.id { selected = value } if value.archived != currentArchived || (currentWorkspace != nil && value.workspaceId != currentWorkspace) { conversations.removeAll { $0.id == value.id } } else if let index = conversations.firstIndex(where: { $0.id == value.id }) { conversations[index] = value } } func patch(_ conversation: HopeConversation, changes: [String: Any]) async { guard canMutate(conversation) else { return } mutatingConversationIds.insert(conversation.id) defer { mutatingConversationIds.remove(conversation.id) } var body = changes; body["revision"] = conversation.revision do { let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body, selectionBound: false) updateMetadata(response.conversation) await refreshList() } catch ChatFailure.status(409) { do { let detail: ConversationDetail = try await call("api/conversations/\(conversation.id)", selectionBound: false) updateMetadata(detail.conversation) await refreshList() guard !closed else { return } error = "This chat changed elsewhere. Its latest details are loaded. Review your change and try again." } catch { await failure(error) } } catch { await failure(error) } } func delete(_ conversation: HopeConversation) async { guard canMutate(conversation) else { return } mutatingConversationIds.insert(conversation.id) defer { mutatingConversationIds.remove(conversation.id) } do { struct Result: Decodable { let ok: Bool } let _: Result = try await call("api/conversations/\(conversation.id)", method: "DELETE", selectionBound: false) conversations.removeAll { $0.id == conversation.id }; drafts[conversation.id] = nil if selected?.id == conversation.id { clearSelection() } await refreshList() } catch { await failure(error) } } private func clearSelection() { generation += 1; draftGeneration += 1; stream?.cancel(); stream = nil selected = nil; messages = []; runs = []; draft = ""; selectedAttachmentIds = [] pendingKey = nil; approval = nil; progress = nil; exportText = nil; exportFile = nil; olderMessagesCursor = nil } func queuedAttachment(_ id: String) -> ChatAttachment? { queuedAttachments[id] ?? features.attachments.first(where: { $0.id == id }) } /// Bind asynchronous library/upload results to the composer that opened them. @discardableResult func queueAttachment(_ attachment: ChatAttachment, expectedDraftGeneration: Int? = nil) async -> Bool { guard !closed, let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id), expectedDraftGeneration == nil || expectedDraftGeneration == draftGeneration else { return false } guard features.enabled.contains("attachments") else { error = "Attachments are unavailable. Refresh Files and try again."; return false } guard attachmentEligible(attachment) else { error = "This file cannot be used with the current model. Choose an extracted document or a supported image."; return false } guard selectedAttachmentIds.contains(attachment.id) || selectedAttachmentIds.count < 8 else { error = "Use at most eight attachments per message."; return false } queuedAttachments[attachment.id] = attachment selectedAttachmentIds.insert(attachment.id); pendingKey = nil; error = nil return true } func branch(_ message: HopeMessage, edited: String? = nil, regenerate: Bool = false) async { guard let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id) else { return } busy = true do { let target: HopeMessage if regenerate { guard let index = messages.firstIndex(where: { $0.id == message.id }), let prior = messages.prefix(index).last(where: { $0.role == "user" }) else { busy = false; return } target = prior } else { target = message } var body: [String: Any] = ["message_id": target.id] if let edited { body["content"] = edited } else if regenerate { body["content"] = target.content } let response: ConversationEnvelope = try await call("api/conversations/\(selected.id)/branch", method: "POST", body: body) let selectionEpoch = generation await refreshList() guard !closed, generation == selectionEpoch else { busy = false; return } await open(response.conversation) guard !closed, self.selected?.id == response.conversation.id else { busy = false; return } if let edited { draft = edited; selectedAttachmentIds = Set(target.attachments) } else if regenerate { draft = target.content; selectedAttachmentIds = Set(target.attachments) } if edited != nil || regenerate, !target.attachments.isEmpty { let attachmentEpoch = generation _ = await features.refreshCapabilities() guard !closed, generation == attachmentEpoch, self.selected?.id == response.conversation.id else { busy = false; return } for id in target.attachments { struct AttachmentEnvelope: Decodable { let attachment: ChatAttachment } let metadata: AttachmentEnvelope = try await call("api/attachments/\(id)") queuedAttachments[id] = metadata.attachment } } busy = false if let edited { draft = edited; selectedAttachmentIds = Set(target.attachments); await send() } else if regenerate { draft = target.content; selectedAttachmentIds = Set(target.attachments); await send() } } catch { busy = false; await failure(error) } } @discardableResult func saveWorkspace(_ workspace: HopeWorkspace?, name: String, instructions: String) async -> Bool { guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { error = "Enter a workspace name." return false } do { var body: [String: Any] = ["name": name, "instructions": instructions] if let workspace { body["revision"] = workspace.revision } let _: WorkspaceEnvelope = try await call(workspace.map { "api/workspaces/\($0.id)" } ?? "api/workspaces", method: workspace == nil ? "POST" : "PATCH", body: body) let result: WorkspacePage = try await call("api/workspaces") workspaces = result.workspaces error = nil return true } catch { await failure(error) return false } } func deleteWorkspace(_ workspace: HopeWorkspace) async { do { struct Result: Decodable { let ok: Bool } let _: Result = try await call("api/workspaces/\(workspace.id)", method: "DELETE") workspaces.removeAll { $0.id == workspace.id } for i in conversations.indices where conversations[i].workspaceId == workspace.id { conversations[i].workspaceId = nil } if selected?.workspaceId == workspace.id { selected?.workspaceId = nil } await refreshList() } catch { await failure(error) } } func savePreferences(palette: String, appearance: String) async { do { let result: PreferencesEnvelope = try await call("api/preferences", method: "PUT", body: ["revision": preferences.revision, "palette": palette, "appearance": appearance]); preferences = result.preferences } catch { await failure(error) } } func loadModels() async { do { let result: ChatSettingsResponse = try await call("api/settings/model") model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; models = result.models; modelNote = result.thinkingNote ?? result.message } catch { modelNote = "Server model settings are not available." } } func saveModel() async { do { let result: ChatSettingsSaved = try await call("api/settings/model", method: "PUT", body: ["model": model, "thinking_level": thinkingLevel], selectionBound: false); model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; modelNote = result.message; _ = await features.refreshCapabilities(); await loadResearch() } catch { await failure(error) } } func loadResearch() async { do { struct Result: Decodable { let available: Bool }; let result: Result = try await call("api/research"); researchAvailable = result.available } catch { researchAvailable = false; research = false } } func exportConversation(format: String) async { guard let selected else { return } do { let epoch = generation let bytes = try await api.request("api/conversations/\(selected.id)/export", method: "GET", body: nil, query: [URLQueryItem(name: "format", value: format)]) guard epoch == generation, !closed else { return } exportText = String(data: bytes, encoding: .utf8) exportFile = PrivateChatExport(data: bytes, filename: "Hope-chat-\(selected.id).\(format == "json" ? "json" : "md")", type: format == "json" ? .json : .plainText) } catch { await failure(error) } } func foreground(_ active: Bool) { syncTask?.cancel(); syncTask = nil guard active, !closed else { stream?.cancel(); stream = nil; return } resume() syncTask = Task { [weak self] in await self?.loadResearch() while !Task.isCancelled { guard self?.closed == false else { return } await self?.synchronize() // Release the store before suspension; its retained task must not // keep an otherwise-discarded account alive indefinitely. do { try await Task.sleep(for: .seconds(5)) } catch { return } } } } func synchronize() async { do { struct Change: Decodable { let id: String; let kind: String; let deleted: Bool; let data: HopeSyncData? } struct Result: Decodable { let changes: [Change]; let cursor: Int } let result: Result = try await call("api/sync", query: [URLQueryItem(name: "after", value: String(syncCursor))], selectionBound: false) syncCursor = result.cursor guard !result.changes.isEmpty else { return } for change in result.changes { if change.kind == "conversation" { if change.deleted { conversations.removeAll { $0.id == change.id } if selected?.id == change.id { clearSelection() } } else if let value = change.data?.conversation { if value.archived != currentArchived || (currentWorkspace != nil && value.workspaceId != currentWorkspace) { conversations.removeAll { $0.id == value.id } } else if let i = conversations.firstIndex(where: { $0.id == change.id }) { conversations[i] = value } else if currentQuery.isEmpty { conversations.insert(value, at: 0) } } } if change.kind == "preferences", let value = change.data?.preferences { preferences = value } if change.kind == "workspace" { if change.deleted { workspaces.removeAll { $0.id == change.id } } else if let value = change.data?.workspace { if let i = workspaces.firstIndex(where: { $0.id == value.id }) { workspaces[i] = value } else { workspaces.append(value) } } } } // Reconcile only loaded metadata. Re-fetching page one here discards // older loaded pages and their continuation cursor every sync tick. // Search membership depends on message content, so newly matching rows // are discovered only by an explicit search refresh, not guessed here. if let selected, result.changes.contains(where: { $0.id == selected.id && !$0.deleted }) { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)") self.selected = detail.conversation for message in detail.messages { upsert(message) } for run in detail.runs { upsert(run) } } } catch { if case ChatFailure.status(let status) = error, status == 401 || status == 403 { await failure(error) } } } /// Invalidate every outstanding callback before clearing account-visible content. func close() { syncTask?.cancel(); syncTask = nil; exportText = nil; exportFile = nil; research = false; features.close(); selectedAttachmentIds = []; queuedAttachments = [:]; closed = true; generation += 1; draftGeneration += 1; searchGeneration += 1; mutatingConversationIds = []; stream?.cancel(); stream = nil; api.cancel(); conversations = []; workspaces = []; selected = nil; messages = []; runs = []; draft = ""; drafts = [:]; cursors = [:]; pendingKey = nil; approval = nil; progress = nil; error = nil } } struct ChatModelChoice: Decodable, Identifiable { let id: String; let thinkingLevels: [String]? } struct ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String } struct ChatSettingsResponse: Decodable { let settings: ChatSettingsValue; let models: [ChatModelChoice]; let message: String?; let thinkingNote: String? } struct ChatSettingsSaved: Decodable { let settings: ChatSettingsValue; let message: String? } struct HopeSyncData: Decodable { let conversation: HopeConversation? let workspace: HopeWorkspace? let preferences: HopePreferences? init(from decoder: Decoder) throws { let value = try decoder.singleValueContainer() conversation = try? value.decode(HopeConversation.self) workspace = try? value.decode(HopeWorkspace.self) preferences = try? value.decode(HopePreferences.self) } }