322 lines
19 KiB
Swift
322 lines
19 KiB
Swift
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 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<Void, Never>?
|
|
@Published var model = ""
|
|
@Published var thinkingLevel = ""
|
|
@Published var models: [ChatModelChoice] = []
|
|
@Published var modelNote: String?
|
|
let features: ChatFeatureStore
|
|
@Published var selectedAttachmentIds: Set<String> = []
|
|
private let api: ChatTransport
|
|
private var generation = 0
|
|
private var stream: Task<Void, Never>?
|
|
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<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
|
|
let epoch = generation
|
|
let data = try await api.request(path, method: method, body: body, query: query)
|
|
try Task.checkCancellation()
|
|
guard !closed, 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, more: Bool = false) async {
|
|
guard !closed else { return }
|
|
currentQuery = query; currentArchived = archived
|
|
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 more, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) }
|
|
let page: ConversationPage = try await call("api/conversations", query: q)
|
|
guard search == searchGeneration else { return }
|
|
if more { 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"); workspaces = spaces.workspaces
|
|
let prefs: PreferencesEnvelope = try await call("api/preferences"); preferences = prefs.preferences
|
|
error = nil
|
|
} catch { await failure(error) }
|
|
}
|
|
func open(_ conversation: HopeConversation) async {
|
|
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)
|
|
conversations.insert(response.conversation, at: 0); await open(response.conversation)
|
|
} catch { await failure(error) }
|
|
}
|
|
func send() async {
|
|
guard let selected, !busy, activeRun == nil else { return }
|
|
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !content.isEmpty else { 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"])
|
|
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 patch(_ conversation: HopeConversation, changes: [String: Any]) async {
|
|
var body = changes; body["revision"] = conversation.revision
|
|
do {
|
|
let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body)
|
|
if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[index] = response.conversation }
|
|
if selected?.id == conversation.id { selected = response.conversation }
|
|
} catch { await failure(error) }
|
|
}
|
|
func delete(_ conversation: HopeConversation) async {
|
|
do {
|
|
struct Result: Decodable { let ok: Bool }
|
|
let _: Result = try await call("api/conversations/\(conversation.id)", method: "DELETE")
|
|
conversations.removeAll { $0.id == conversation.id }; drafts[conversation.id] = nil
|
|
if selected?.id == conversation.id { generation += 1; stream?.cancel(); selected = nil; messages = []; runs = []; draft = "" }
|
|
} catch { await failure(error) }
|
|
}
|
|
func branch(_ message: HopeMessage, edited: String? = nil, regenerate: Bool = false) async {
|
|
guard let selected, !busy, activeRun == nil 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)
|
|
conversations.insert(response.conversation, at: 0); await open(response.conversation)
|
|
busy = false
|
|
if let edited { draft = edited; await send() }
|
|
else if regenerate { draft = target.content; await send() }
|
|
} catch { busy = false; await failure(error) }
|
|
}
|
|
func saveWorkspace(_ workspace: HopeWorkspace?, name: String, instructions: String) async {
|
|
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
|
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
|
|
} catch { await failure(error) }
|
|
}
|
|
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 }
|
|
} 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]); model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; modelNote = result.message }
|
|
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 }
|
|
}
|
|
}
|
|
}
|
|
private 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))])
|
|
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 { stream?.cancel(); self.selected = nil; messages = []; runs = []; draft = "" }
|
|
} else if let value = change.data?.conversation {
|
|
if value.archived != currentArchived { 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) } }
|
|
}
|
|
}
|
|
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 = []; closed = true; generation += 1; 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)
|
|
}
|
|
}
|