import Foundation struct HopeConversation: Codable, Identifiable, Equatable { var id: String var title: String var workspaceId: String? var pinned: Bool var pinRank: Int var archived: Bool var temporary: Bool var revision: Int var createdAt: Double var updatedAt: Double } struct HopeMessage: Codable, Identifiable, Equatable { var id: String var conversationId: String var role: String var content: String var status: String var runId: String? var createdAt: Double var attachments: [String] = [] enum CodingKeys: String, CodingKey { case id, conversationId, role, content, status, runId, createdAt, attachments } init(id: String, conversationId: String, role: String, content: String, status: String, runId: String?, createdAt: Double, attachments: [String] = []) { self.id = id; self.conversationId = conversationId; self.role = role; self.content = content; self.status = status; self.runId = runId; self.createdAt = createdAt; self.attachments = attachments } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) conversationId = try values.decode(String.self, forKey: .conversationId) role = try values.decode(String.self, forKey: .role) content = try values.decode(String.self, forKey: .content) status = try values.decode(String.self, forKey: .status) runId = try values.decodeIfPresent(String.self, forKey: .runId) createdAt = try values.decode(Double.self, forKey: .createdAt) attachments = try values.decodeIfPresent([String].self, forKey: .attachments) ?? [] } } struct HopeRun: Codable, Identifiable, Equatable { var id: String var conversationId: String var status: String var messageId: String var createdAt: Double var updatedAt: Double var error: String? var active: Bool { ["queued", "running", "awaiting_approval"].contains(status) } } struct HopeWorkspace: Codable, Identifiable, Equatable { var id: String var name: String var instructions: String var revision: Int var createdAt: Double var updatedAt: Double } struct HopePreferences: Codable, Equatable { var palette: String = "opal" var appearance: String = "system" var revision: Int = 0 } struct ConversationPage: Decodable { let conversations: [HopeConversation]; let nextCursor: String? } struct ConversationDetail: Decodable { let conversation: HopeConversation; let messages: [HopeMessage]; let runs: [HopeRun]; let nextCursor: String? } struct ConversationEnvelope: Decodable { let conversation: HopeConversation } struct WorkspaceEnvelope: Decodable { let workspace: HopeWorkspace } struct WorkspacePage: Decodable { let workspaces: [HopeWorkspace] } struct PreferencesEnvelope: Decodable { let preferences: HopePreferences } struct SendEnvelope: Decodable { let message: HopeMessage; let run: HopeRun } struct RunEnvelope: Decodable { let run: HopeRun } struct RunDetail: Decodable { let run: HopeRun; let messages: [HopeMessage] } struct ChatEvent: Equatable { let id: Int; let name: String; let data: Data } /// Incremental SSE framing. A cursor advances only after a complete event is applied. struct SSEParser { private var id = 0 private var name = "message" private var lines: [String] = [] mutating func consume(_ line: String) -> ChatEvent? { if line.isEmpty { defer { id = 0; name = "message"; lines = [] } guard id > 0, !lines.isEmpty else { return nil } return ChatEvent(id: id, name: name, data: Data(lines.joined(separator: "\n").utf8)) } if line.hasPrefix(":") { return nil } let pair = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) let value = pair.count > 1 ? String(pair[1]).replacingOccurrences(of: "^ ", with: "", options: .regularExpression) : "" switch pair.first { case "id": id = Int(value) ?? 0; case "event": name = value; case "data": lines.append(value); default: break } return nil } } enum ChatCoding { static var decoder: JSONDecoder { let d = JSONDecoder(); d.keyDecodingStrategy = .convertFromSnakeCase; return d } static func body(_ value: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: value) } } enum ChatFailure: LocalizedError { case status(Int), invalidResponse, unavailable var errorDescription: String? { switch self { case .status(409): return "This chat changed elsewhere. Refresh and try again." case .status(401), .status(403): return "Your session is no longer authorized. Please sign in again." case .status(404): return "This chat is no longer available." case .status(503), .unavailable: return "Chat is not available on this server yet. Talk remains available." default: return "Hope couldn’t complete that request. Your draft is still here. Try again." } } } protocol ChatTransport { func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws func upload(data: Data, filename: String, mime: String) async throws -> Data func cancel() } extension ChatTransport { func upload(data: Data, filename: String, mime: String) async throws -> Data { throw ChatFailure.unavailable } } final class ChatAPI: ChatTransport { let config: ServerConfig private let token: String private let session: URLSession init(config: ServerConfig, token: String, session: URLSession? = nil) { self.config = config; self.token = token; self.session = session ?? TLSPinning.session() } private func makeRequest(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) throws -> URLRequest { guard !token.isEmpty else { throw ChatFailure.status(401) } var components = URLComponents(url: config.baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)! components.queryItems = query.isEmpty ? nil : query var request = URLRequest(url: components.url!) request.httpMethod = method; request.timeoutInterval = 60 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") if let body { request.httpBody = try ChatCoding.body(body); request.setValue("application/json", forHTTPHeaderField: "Content-Type") } return request } func request(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> Data { let (data, response) = try await session.data(for: makeRequest(path, method: method, body: body, query: query)) try validate(response) return data } private func validate(_ response: URLResponse) throws { guard let response = response as? HTTPURLResponse else { throw ChatFailure.invalidResponse } guard (200..<300).contains(response.statusCode) else { throw ChatFailure.status(response.statusCode) } } func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws { var request = try makeRequest("api/runs/\(run)/events", method: "GET", body: nil, query: [URLQueryItem(name: "after", value: String(after))]) request.setValue("text/event-stream", forHTTPHeaderField: "Accept") let (bytes, response) = try await session.bytes(for: request) try validate(response) var parser = SSEParser() for try await line in bytes.lines { try Task.checkCancellation() if let event = parser.consume(line) { try await receive(event); if event.name == "done" { return } } } } func upload(data: Data, filename: String, mime: String) async throws -> Data { var request = try makeRequest("api/attachments", method: "POST", body: nil, query: []) request.httpBody = data request.setValue(mime, forHTTPHeaderField: "Content-Type") request.setValue(filename, forHTTPHeaderField: "X-Filename") let (result, response) = try await session.data(for: request) try validate(response) return result } func cancel() { session.invalidateAndCancel() } } /// Offline tests never hit any server. Uses the same contract envelopes as HTTP. final class OfflineChatAPI: ChatTransport { private var conversations: [HopeConversation] = [] private var messages: [String: [HopeMessage]] = [:] private var workspaces: [HopeWorkspace] = [] private var preferences = HopePreferences() private var operations: [String: Data] = [:] private var featureObjects: [String: [[String: Any]]] = ["attachments": [], "artifacts": [], "tasks": []] private var fileData: [String: Data] = [:] private var memories: [[String: Any]] = [["section": "memory", "content": "Offline memory", "revision": "1"], ["section": "user", "content": "", "revision": "1"]] private func encode(_ value: T) throws -> Data { let e = JSONEncoder(); e.keyEncodingStrategy = .convertToSnakeCase; return try e.encode(value) } func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data { try Task.checkCancellation() let now = Date().timeIntervalSince1970 let parts = path.split(separator: "/").map(String.init) let body = body ?? [:] if path == "api/capabilities" { return Data("{\"features\":{\"attachments\":true,\"artifacts\":true,\"scheduled_tasks\":true,\"memory_controls\":true,\"image_input\":false}}".utf8) } if path == "api/research" { return Data("{\"available\":false,\"reason\":\"Offline fixture\"}".utf8) } if path == "api/sync" { return Data("{\"changes\":[],\"cursor\":0}".utf8) } if parts.count == 4, parts[1] == "conversations", parts[3] == "export" { guard let conversation = conversations.first(where: { $0.id == parts[2] }) else { throw ChatFailure.status(404) } return Data(("# " + conversation.title + "\n\n" + (messages[conversation.id] ?? []).map { "## " + $0.role + "\n\n" + $0.content }.joined(separator: "\n\n")).utf8) } if path == "api/memory" { return try JSONSerialization.data(withJSONObject: ["sections": memories]) } if parts.count == 3, parts[1] == "memory", let i = memories.firstIndex(where: { $0["section"] as? String == parts[2] }) { guard body["revision"] as? String == memories[i]["revision"] as? String else { throw ChatFailure.status(409) } memories[i]["content"] = body["content"] as? String ?? ""; memories[i]["revision"] = UUID().uuidString return try JSONSerialization.data(withJSONObject: memories[i]) } if parts.count >= 2, ["attachments", "artifacts", "tasks"].contains(parts[1]) { let kind = parts[1]; let singular = kind == "tasks" ? "task" : String(kind.dropLast()) if parts.count == 2 { if method == "GET" { return try JSONSerialization.data(withJSONObject: [kind: featureObjects[kind] ?? [], "next_cursor": NSNull()]) } var item = body; item["id"] = UUID().uuidString; item["created_at"] = now; item["updated_at"] = now if kind == "artifacts" { item["revision"] = 1 } if kind == "tasks" { item["enabled"] = !(body["paused"] as? Bool ?? false) } featureObjects[kind, default: []].insert(item, at: 0) return try JSONSerialization.data(withJSONObject: [singular: item]) } guard let i = featureObjects[kind]?.firstIndex(where: { $0["id"] as? String == parts[2] }) else { throw ChatFailure.status(404) } if parts.count == 4, parts[3] == "content" { return fileData[parts[2]] ?? Data() } if method == "DELETE" { featureObjects[kind]?.remove(at: i); fileData[parts[2]] = nil; return Data("{\"ok\":true}".utf8) } var item = featureObjects[kind]![i] if method == "PATCH" { if kind == "artifacts" { guard body["revision"] as? Int == item["revision"] as? Int else { throw ChatFailure.status(409) }; item["revision"] = (item["revision"] as? Int ?? 1) + 1 } for (key, value) in body where key != "revision" { item[key] = value } } if parts.count == 4 { if parts[3] == "pause" { item["enabled"] = false }; if parts[3] == "resume" { item["enabled"] = true } } featureObjects[kind]![i] = item return try JSONSerialization.data(withJSONObject: [singular: item]) } if path == "api/preferences" { if method == "PUT" { preferences.palette = body["palette"] as? String ?? "opal"; preferences.appearance = body["appearance"] as? String ?? "system"; preferences.revision += 1 } return try JSONSerialization.data(withJSONObject: ["preferences": JSONSerialization.jsonObject(with: encode(preferences))]) } if path == "api/workspaces" { if method == "POST" { workspaces.append(HopeWorkspace(id: UUID().uuidString, name: body["name"] as? String ?? "Workspace", instructions: body["instructions"] as? String ?? "", revision: 1, createdAt: now, updatedAt: now)); return try wrap("workspace", workspaces.last!) } return try JSONSerialization.data(withJSONObject: ["workspaces": JSONSerialization.jsonObject(with: encode(workspaces))]) } if parts.count == 3, parts[1] == "workspaces", let index = workspaces.firstIndex(where: { $0.id == parts[2] }) { if method == "DELETE" { workspaces.remove(at: index); for i in conversations.indices where conversations[i].workspaceId == parts[2] { conversations[i].workspaceId = nil }; return Data("{\"ok\":true}".utf8) } workspaces[index].name = body["name"] as? String ?? workspaces[index].name workspaces[index].instructions = body["instructions"] as? String ?? workspaces[index].instructions workspaces[index].revision += 1 return try wrap("workspace", workspaces[index]) } if path == "api/conversations" { if method == "POST" { let c = HopeConversation(id: UUID().uuidString, title: body["title"] as? String ?? "New chat", workspaceId: body["workspace_id"] as? String, pinned: false, pinRank: 0, archived: false, temporary: false, revision: 1, createdAt: now, updatedAt: now); conversations.insert(c, at: 0); return try wrap("conversation", c) } let q = query.first(where: { $0.name == "q" })?.value ?? "" let archived = query.first(where: { $0.name == "archived" })?.value == "true" let workspace = query.first(where: { $0.name == "workspace_id" })?.value let filtered = conversations.filter { $0.archived == archived && (workspace == nil || $0.workspaceId == workspace) && (q.isEmpty || $0.title.localizedCaseInsensitiveContains(q) || (messages[$0.id] ?? []).contains { $0.content.localizedCaseInsensitiveContains(q) }) } return try JSONSerialization.data(withJSONObject: ["conversations": JSONSerialization.jsonObject(with: encode(filtered)), "next_cursor": NSNull()]) } if parts.count >= 3, parts[1] == "conversations", let index = conversations.firstIndex(where: { $0.id == parts[2] }) { let id = parts[2] if parts.count == 4, parts[3] == "messages" { let key = body["idempotency_key"] as? String ?? UUID().uuidString if let prior = operations[key] { return prior } let runId = UUID().uuidString let user = HopeMessage(id: UUID().uuidString, conversationId: id, role: "user", content: body["content"] as? String ?? "", status: "completed", runId: runId, createdAt: now) let answer = HopeMessage(id: UUID().uuidString, conversationId: id, role: "assistant", content: "## Offline reply\n\nThis is a **local fixture**, not a server response.\n\n```swift\nlet hope = true\n```", status: "completed", runId: runId, createdAt: now) messages[id, default: []] += [user, answer] let run = HopeRun(id: runId, conversationId: id, status: "completed", messageId: answer.id, createdAt: now, updatedAt: now, error: nil) let data = try JSONSerialization.data(withJSONObject: ["message": JSONSerialization.jsonObject(with: encode(user)), "run": JSONSerialization.jsonObject(with: encode(run))]); operations[key] = data; return data } if parts.count == 4, parts[3] == "branch" { var c = conversations[index]; c.id = UUID().uuidString; c.title += " · Branch"; c.revision = 1 let source = messages[id] ?? []; let cutoff = source.firstIndex { $0.id == body["message_id"] as? String } ?? source.count messages[c.id] = Array(source.prefix(cutoff)).map { var m = $0; m.conversationId = c.id; return m } conversations.insert(c, at: 0); return try wrap("conversation", c) } if method == "DELETE" { conversations.remove(at: index); messages[id] = nil; return Data("{\"ok\":true}".utf8) } if method == "PATCH" { guard body["revision"] as? Int == conversations[index].revision else { throw ChatFailure.status(409) } conversations[index].title = body["title"] as? String ?? conversations[index].title conversations[index].pinned = body["pinned"] as? Bool ?? conversations[index].pinned conversations[index].archived = body["archived"] as? Bool ?? conversations[index].archived if body.keys.contains("workspace_id") { conversations[index].workspaceId = body["workspace_id"] as? String } conversations[index].revision += 1; return try wrap("conversation", conversations[index]) } return try JSONSerialization.data(withJSONObject: ["conversation": JSONSerialization.jsonObject(with: encode(conversations[index])), "messages": JSONSerialization.jsonObject(with: encode(messages[id] ?? [])), "runs": [], "next_cursor": NSNull()]) } throw ChatFailure.status(404) } private func wrap(_ key: String, _ value: T) throws -> Data { try JSONSerialization.data(withJSONObject: [key: JSONSerialization.jsonObject(with: encode(value))]) } func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {} func upload(data: Data, filename: String, mime: String) async throws -> Data { let id = UUID().uuidString let item: [String: Any] = ["id": id, "filename": filename, "mime": mime, "size": data.count, "extraction_status": mime.hasPrefix("image/") ? "image" : "ready", "created_at": Date().timeIntervalSince1970] fileData[id] = data; featureObjects["attachments", default: []].insert(item, at: 0) return try JSONSerialization.data(withJSONObject: ["attachment": item]) } func cancel() {} }