286 lines
19 KiB
Swift
286 lines
19 KiB
Swift
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<T: Encodable>(_ 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<T: Encodable>(_ 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() {}
|
||
}
|