feat(ios): align chat library memory and tasks with web
This commit is contained in:
1 parent
8905dc2c2a
commit
235dd65487
12 files changed
+957
-128
No files matched your search
@@ -17,6 +17,9 @@ final class ChatStore: ObservableObject {
|
||||
@Published var olderMessagesCursor: String?
|
||||
private var currentQuery = ""
|
||||
private var currentArchived = false
|
||||
private var currentWorkspace: String?
|
||||
@Published private(set) var mutatingConversationIds: Set<String> = []
|
||||
private(set) var draftGeneration = 0
|
||||
private var searchGeneration = 0
|
||||
@Published var approval: (id: String, prompt: String)?
|
||||
@Published var researchAvailable = false
|
||||
@@ -31,6 +34,22 @@ final class ChatStore: ObservableObject {
|
||||
@Published var modelNote: String?
|
||||
let features: ChatFeatureStore
|
||||
@Published var selectedAttachmentIds: Set<String> = []
|
||||
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<Void, Never>?
|
||||
@@ -43,11 +62,11 @@ final class ChatStore: ObservableObject {
|
||||
|
||||
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 {
|
||||
private func call<T: Decodable>(_ 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, generation == epoch else { throw CancellationError() }
|
||||
guard !closed, !selectionBound || generation == epoch else { throw CancellationError() }
|
||||
return try ChatCoding.decoder.decode(T.self, from: data)
|
||||
}
|
||||
private func failure(_ value: Error) async {
|
||||
@@ -55,27 +74,33 @@ final class ChatStore: ObservableObject {
|
||||
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 {
|
||||
func load(query: String = "", archived: Bool = false, workspace: String? = nil, more: Bool = false) async {
|
||||
guard !closed else { return }
|
||||
currentQuery = query; currentArchived = archived
|
||||
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 more, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) }
|
||||
let page: ConversationPage = try await call("api/conversations", query: q)
|
||||
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 { let existing = Set(conversations.map(\.id)); conversations += page.conversations.filter { !existing.contains($0.id) } }
|
||||
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"); workspaces = spaces.workspaces
|
||||
let prefs: PreferencesEnvelope = try await call("api/preferences"); preferences = prefs.preferences
|
||||
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
|
||||
@@ -100,18 +125,26 @@ final class ChatStore: ObservableObject {
|
||||
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)
|
||||
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 else { return }
|
||||
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 }
|
||||
@@ -173,24 +206,78 @@ final class ChatStore: ObservableObject {
|
||||
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)
|
||||
if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[index] = response.conversation }
|
||||
if selected?.id == conversation.id { selected = response.conversation }
|
||||
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")
|
||||
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 { generation += 1; stream?.cancel(); selected = nil; messages = []; runs = []; draft = "" }
|
||||
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 else { return }
|
||||
guard let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id) else { return }
|
||||
busy = true
|
||||
do {
|
||||
let target: HopeMessage
|
||||
@@ -202,10 +289,26 @@ final class ChatStore: ObservableObject {
|
||||
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)
|
||||
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; await send() }
|
||||
else if regenerate { draft = target.content; await send() }
|
||||
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
|
||||
@@ -233,6 +336,8 @@ final class ChatStore: ObservableObject {
|
||||
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 {
|
||||
@@ -246,7 +351,7 @@ final class ChatStore: ObservableObject {
|
||||
} 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 }
|
||||
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 {
|
||||
@@ -278,20 +383,20 @@ final class ChatStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
private func synchronize() async {
|
||||
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))])
|
||||
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 { stream?.cancel(); self.selected = nil; messages = []; runs = []; draft = "" }
|
||||
if selected?.id == change.id { clearSelection() }
|
||||
} else if let value = change.data?.conversation {
|
||||
if value.archived != currentArchived { conversations.removeAll { $0.id == value.id } }
|
||||
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) }
|
||||
}
|
||||
@@ -302,6 +407,10 @@ final class ChatStore: ObservableObject {
|
||||
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
|
||||
@@ -311,7 +420,7 @@ final class ChatStore: ObservableObject {
|
||||
} 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 }
|
||||
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 }
|
||||
|
||||
Reference in new issue
Block a user