Files
hope-voice-api/Sources/Chat/ChatModels.swift
T

270 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// Attachments are deliberately unavailable until their contract is qualified.
}
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"] = true }
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 filtered = conversations.filter { $0.archived == archived && (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() {}
}