feat: add authenticated durable native Hope chat

This commit is contained in:
snowspeeder committed 2026-10-03 09:06:06 -04:00
1 parent 74d2b63614
commit 7dcd06c911
15 files changed
+1675 -9

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
import SwiftUI
import UniformTypeIdentifiers
import PhotosUI
import PDFKit
struct ChatAttachment: Codable, Identifiable { var id: String; var filename: String; var mime: String; var size: Int; var extractionStatus: String; var createdAt: Double }
struct ChatArtifact: Codable, Identifiable { var id: String; var title: String; var kind: String; var revision: Int; var content: String?; var createdAt: Double; var updatedAt: Double }
struct ScheduledChatTask: Codable, Identifiable { var id: String; var name: String?; var schedule: String?; var prompt: String?; var enabled: Bool?; var `repeat`: Int? }
struct ChatMemory: Codable, Identifiable { var section: String; var content: String; var revision: String; var id: String { section } }
struct FeatureCapabilities: Decodable { let features: [String: BoolValue] }
/// Capabilities may carry limits/strings alongside bool flags; only explicit true enables UI.
struct BoolValue: Decodable { let enabled: Bool; init(from decoder: Decoder) throws { enabled = (try? decoder.singleValueContainer().decode(Bool.self)) == true } }
@MainActor
final class ChatFeatureStore: ObservableObject {
@Published var enabled: Set<String> = []
@Published var attachments: [ChatAttachment] = []
@Published var artifacts: [ChatArtifact] = []
@Published var tasks: [ScheduledChatTask] = []
@Published var memory: [ChatMemory] = []
@Published var error: String?
@Published var busy = false
@Published var previewText: String?
@Published var previewImage: UIImage?
@Published var previewPDF: Data?
var onUnauthorized: (() async -> Void)?
private let api: ChatTransport
private var epoch = 0
private var closed = false
init(api: ChatTransport) { self.api = api }
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
let current = epoch
let data = try await api.request(path, method: method, body: body, query: query)
guard !closed, current == epoch else { throw CancellationError() }
try Task.checkCancellation()
return try ChatCoding.decoder.decode(T.self, from: data)
}
func load() async {
do {
let caps: FeatureCapabilities = try await call("api/capabilities")
enabled = Set(caps.features.filter { $0.value.enabled }.map(\.key))
if enabled.contains("attachments") { struct Page: Decodable { let attachments: [ChatAttachment] }; let page: Page = try await call("api/attachments"); attachments = page.attachments }
if enabled.contains("artifacts") { struct Page: Decodable { let artifacts: [ChatArtifact] }; let page: Page = try await call("api/artifacts"); artifacts = page.artifacts }
if enabled.contains("scheduled_tasks") { struct Page: Decodable { let tasks: [ScheduledChatTask] }; let page: Page = try await call("api/tasks"); tasks = page.tasks }
if enabled.contains("memory_controls") { struct Page: Decodable { let sections: [ChatMemory] }; let page: Page = try await call("api/memory"); memory = page.sections }
error = nil
} catch { report(error) }
}
private func report(_ value: Error) {
guard !(value is CancellationError), !closed else { return }
if case ChatFailure.status(let code) = value, code == 401 || code == 403 { Task { await onUnauthorized?() }; return }
error = value.localizedDescription
}
func upload(_ data: Data, filename: String, mime: String) async -> ChatAttachment? {
guard !busy, enabled.contains("attachments") else { return nil }
guard data.count <= 8 * 1024 * 1024 else { error = "Choose a file smaller than 8 MiB."; return nil }
busy = true; defer { busy = false }
do {
let current = epoch
struct Envelope: Decodable { let attachment: ChatAttachment }
let bytes = try await api.upload(data: data, filename: filename, mime: mime)
guard current == epoch, !closed else { return nil }
let value = try ChatCoding.decoder.decode(Envelope.self, from: bytes).attachment
attachments.insert(value, at: 0); error = nil; return value
} catch { report(error); return nil }
}
func preview(_ attachment: ChatAttachment) async {
do {
let current = epoch
let data = try await api.request("api/attachments/\(attachment.id)/content", method: "GET", body: nil, query: [])
guard current == epoch, !closed else { return }
previewPDF = nil; previewText = nil; previewImage = nil
if attachment.mime.hasPrefix("image/") { previewImage = UIImage(data: data); previewText = nil }
else if attachment.mime == "application/pdf" { previewPDF = data; previewText = nil; previewImage = nil }
else { previewText = String(data: data, encoding: .utf8); previewImage = nil }
} catch { report(error) }
}
func deleteAttachment(_ item: ChatAttachment) async {
await remove("api/attachments/\(item.id)"); if error == nil { attachments.removeAll { $0.id == item.id } }
}
private func remove(_ path: String) async {
do { struct Result: Decodable { let ok: Bool }; let _: Result = try await call(path, method: "DELETE"); error = nil } catch { report(error) }
}
func saveArtifact(_ item: ChatArtifact?, title: String, kind: String, content: String) async {
do {
var body: [String: Any] = ["title": title, "kind": kind, "content": content]
if let item { body["revision"] = item.revision }
struct Result: Decodable { let artifact: ChatArtifact }
let result: Result = try await call(item.map { "api/artifacts/\($0.id)" } ?? "api/artifacts", method: item == nil ? "POST" : "PATCH", body: body)
if let i = artifacts.firstIndex(where: { $0.id == result.artifact.id }) { artifacts[i] = result.artifact } else { artifacts.insert(result.artifact, at: 0) }; error = nil
} catch { report(error) }
}
func detail(_ item: ChatArtifact) async -> ChatArtifact? {
do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)"); return result.artifact } catch { report(error); return nil }
}
func historical(_ item: ChatArtifact, revision: Int) async -> ChatArtifact? {
do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)", query: [URLQueryItem(name: "revision", value: String(revision))]); return result.artifact } catch { report(error); return nil }
}
func deleteArtifact(_ item: ChatArtifact) async { await remove("api/artifacts/\(item.id)"); if error == nil { artifacts.removeAll { $0.id == item.id } } }
func saveTask(_ item: ScheduledChatTask?, name: String, schedule: String, prompt: String) async {
do {
struct Result: Decodable { let task: ScheduledChatTask }
let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: ["name": name, "schedule": schedule, "prompt": prompt])
if let i = tasks.firstIndex(where: { $0.id == result.task.id }) { tasks[i] = result.task } else { tasks.insert(result.task, at: 0) }; error = nil
} catch { report(error) }
}
func taskAction(_ item: ScheduledChatTask, action: String) async {
do { struct Result: Decodable { let task: ScheduledChatTask }; let result: Result = try await call("api/tasks/\(item.id)/\(action)", method: "POST"); if let i = tasks.firstIndex(where: { $0.id == item.id }) { tasks[i] = result.task }; error = nil } catch { report(error) }
}
func deleteTask(_ item: ScheduledChatTask) async { await remove("api/tasks/\(item.id)"); if error == nil { tasks.removeAll { $0.id == item.id } } }
func saveMemory(_ item: ChatMemory, content: String) async {
do { let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content]); if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result }; error = nil } catch { report(error) }
}
func close() { closed = true; epoch += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; previewText = nil; previewImage = nil; previewPDF = nil; error = nil }
}
struct ChatLibraryView: View {
@ObservedObject var store: ChatFeatureStore
@Binding var selectedAttachmentIds: Set<String>
@Environment(\.dismiss) private var dismiss
@State private var importing = false
@State private var photo: PhotosPickerItem?
@State private var showingPreview = false
@State private var artifact: ChatArtifact?
@State private var artifactEditor = false
@State private var title = ""
@State private var kind = "markdown"
@State private var content = ""
@State private var task: ScheduledChatTask?
@State private var taskEditor = false
@State private var name = ""
@State private var schedule = ""
@State private var prompt = ""
@State private var memory: ChatMemory?
@State private var memoryContent = ""
@State private var deleting: (() async -> Void)?
var body: some View {
NavigationStack {
List {
if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } }
if store.enabled.isEmpty { ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown.")) }
if store.enabled.contains("attachments") {
Section("Files") {
Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy)
if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Import photo", systemImage: "photo") }.disabled(store.busy) }
ForEach(store.attachments) { item in
VStack(alignment: .leading) {
HStack {
Button { if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) } else if selectedAttachmentIds.count < 8 { selectedAttachmentIds.insert(item.id) } } label: { Label(item.filename, systemImage: selectedAttachmentIds.contains(item.id) ? "checkmark.circle.fill" : "circle").frame(minHeight: 44) }.disabled(item.extractionStatus == "failed" || item.extractionStatus == "encrypted" || (item.mime.hasPrefix("image/") && !store.enabled.contains("image_input")))
Button("Preview") { Task { await store.preview(item); showingPreview = true } }.frame(minHeight: 44)
}
Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary)
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); selectedAttachmentIds.remove(item.id) } } }
}
Text("Select up to 8 files for the next message. Uploading alone does not send them.").font(.footnote).foregroundStyle(.secondary)
}
}
if store.enabled.contains("artifacts") {
Section("Artifacts") {
Button("New artifact", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true }
ForEach(store.artifacts) { item in Button { Task { if let detail = await store.detail(item) { artifact = detail; title = detail.title; kind = detail.kind; content = detail.content ?? ""; artifactEditor = true } } } label: { VStack(alignment: .leading) { Text(item.title); Text("Revision \(item.revision) · \(item.kind)").font(.caption).foregroundStyle(.secondary) } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteArtifact(item) } } } }
}
}
if store.enabled.contains("scheduled_tasks") {
Section("Scheduled tasks") {
Text("Tasks run on your Hope server. Push notifications and task output history are not available in this app.").font(.footnote).foregroundStyle(.secondary)
Button("New scheduled task", systemImage: "calendar.badge.plus") { task = nil; name = ""; schedule = ""; prompt = ""; taskEditor = true }
ForEach(store.tasks) { item in
VStack(alignment: .leading) {
Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; taskEditor = true }.frame(minHeight: 44)
Text(item.schedule ?? "Schedule not provided").font(.caption).foregroundStyle(.secondary)
HStack { Button(item.enabled == false ? "Resume" : "Pause") { Task { await store.taskAction(item, action: item.enabled == false ? "resume" : "pause") } }; Button("Run now") { Task { await store.taskAction(item, action: "run") } } }
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteTask(item) } } }
}
}
}
if store.enabled.contains("memory_controls") {
Section("Account memory") {
ForEach(store.memory) { item in Button(item.section.capitalized) { memory = item; memoryContent = item.content } }
Text("Changes may take effect on the next Hermes run or session. Clearing memory does not delete chats or all account data.").font(.footnote).foregroundStyle(.secondary)
}
}
}
.navigationTitle("Library and tools")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.task { await store.load() }
.fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in
if case .success(let url) = result { Task {
let scoped = url.startAccessingSecurityScopedResource(); defer { if scoped { url.stopAccessingSecurityScopedResource() } }
do {
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
guard size <= 8 * 1024 * 1024 else { store.error = "Choose a file smaller than 8 MiB."; return }
let mime: String
switch url.pathExtension.lowercased() { case "md": mime = "text/markdown"; case "pdf": mime = "application/pdf"; case "png": mime = "image/png"; case "jpg", "jpeg": mime = "image/jpeg"; case "webp": mime = "image/webp"; default: mime = "text/plain" }
_ = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime)
} catch { store.error = "Could not read this file. Select it again to retry." }
} }
}
.onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { _ = await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } }
.sheet(isPresented: $showingPreview) { NavigationStack { ScrollView { if let pdf = store.previewPDF { PrivatePDFPreview(data: pdf).frame(minHeight: 500) }; if let image = store.previewImage { Image(uiImage: image).resizable().scaledToFit() }; if let text = store.previewText { Text(text).textSelection(.enabled).padding() } }.navigationTitle("Private preview").toolbar { Button("Done") { showingPreview = false; store.previewImage = nil; store.previewText = nil; store.previewPDF = nil } } } }
.sheet(isPresented: $artifactEditor) { artifactForm }
.sheet(isPresented: $taskEditor) { taskForm }
.sheet(item: $memory) { item in NavigationStack { Form { TextEditor(text: $memoryContent).frame(minHeight: 260); Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote) }.navigationTitle(item.section.capitalized).toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveMemory(item, content: memoryContent); if store.error == nil { memory = nil } } } } } } }
.confirmationDialog("Delete this item?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let deleting { Task { await deleting() } }; deleting = nil } }
}
}
private var artifactForm: some View {
NavigationStack {
Form {
TextField("Title", text: $title)
Picker("Kind", selection: $kind) { ForEach(["markdown", "text", "code"], id: \.self) { Text($0.capitalized).tag($0) } }
TextEditor(text: $content).frame(minHeight: 220)
if let artifact {
ShareLink("Export text", item: content)
if artifact.revision > 1 { Menu("View earlier revision") { ForEach(1..<artifact.revision, id: \.self) { revision in Button("Revision \(revision)") { Task { if let value = await store.historical(artifact, revision: revision) { content = value.content ?? "" } } } } } }
Text("Historical content uses the current title and kind. Saving creates a new revision.").font(.footnote)
}
if let error = store.error { Text(error).foregroundStyle(.red) }
}.navigationTitle(artifact == nil ? "New artifact" : "Edit artifact")
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { artifactEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveArtifact(artifact, title: title, kind: kind, content: content); if store.error == nil { artifactEditor = false } } }.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }
}
}
private var taskForm: some View {
NavigationStack {
Form { TextField("Name", text: $name); TextField("Hermes schedule", text: $schedule); Text("Use a schedule accepted by your Hermes server. Hope does not invent a separate scheduling format.").font(.footnote); TextEditor(text: $prompt).frame(minHeight: 180); if let error = store.error { Text(error).foregroundStyle(.red) } }
.navigationTitle(task == nil ? "New task" : "Edit task")
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveTask(task, name: name, schedule: schedule, prompt: prompt); if store.error == nil { taskEditor = false } } }.disabled(name.isEmpty || schedule.isEmpty || prompt.isEmpty) } }
}
}
}
struct PrivatePDFPreview: UIViewRepresentable {
let data: Data
func makeUIView(context: Context) -> PDFView { let view = PDFView(); view.autoScales = true; view.document = PDFDocument(data: data); return view }
func updateUIView(_ view: PDFView, context: Context) { if view.document?.dataRepresentation() != data { view.document = PDFDocument(data: data) } }
}
+269
View File
@@ -0,0 +1,269 @@
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() {}
}
+316
View File
@@ -0,0 +1,316 @@
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?
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() }
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
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)
} catch { await failure(error) }
}
func foreground(_ active: Bool) {
syncTask?.cancel(); syncTask = nil
guard active, !closed else { return }
syncTask = Task { [weak self] in
guard let self else { return }
await loadResearch()
while !Task.isCancelled, !closed {
await synchronize()
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; 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)
}
}
+282
View File
@@ -0,0 +1,282 @@
import SwiftUI
import UIKit
struct NativeChatView: View {
@ObservedObject var store: ChatStore
let talk: (String) async -> Void
let logout: () async -> Void
@State private var query = ""
@State private var archived = false
@State private var workspace: String?
@State private var path: [String] = []
@State private var rename: HopeConversation?
@State private var delete: HopeConversation?
@State private var title = ""
@State private var showPreferences = false
@State private var showWorkspaces = false
@State private var showLibrary = false
@Environment(\.scenePhase) private var scenePhase
var body: some View {
NavigationStack(path: $path) {
List {
if let error = store.error { Section { Label(error, systemImage: "exclamationmark.circle").foregroundStyle(.red); Button("Retry") { Task { await refresh() } } } }
if store.searching && store.conversations.isEmpty { ProgressView("Loading chats") }
if store.conversations.isEmpty && !store.searching {
ContentUnavailableView(archived ? "No archived chats" : "A place to think", systemImage: "bubble.left.and.bubble.right", description: Text(archived ? "Archived chats appear here." : "Start a chat. Your conversations stay with your account, independently of Talk."))
}
if !pinned.isEmpty { Section("Pinned") { rows(pinned) } }
Section(archived ? "Archived" : "Recent chats") { rows(recent) }
if store.nextCursor != nil { Button("Load more") { Task { await store.load(query: query, archived: archived, more: true) } } }
}
.scrollContentBackground(.hidden)
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.navigationTitle("Chats")
.searchable(text: $query, prompt: "Search chats and messages")
.task(id: query + String(archived)) {
do { try await Task.sleep(for: .milliseconds(250)); try Task.checkCancellation(); await refresh() } catch {}
}
.refreshable { await refresh() }
.onAppear { store.foreground(scenePhase == .active) }
.onChange(of: scenePhase) { _, phase in store.foreground(phase == .active) }
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Menu {
Toggle("Show archived", isOn: $archived)
Picker("Workspace", selection: $workspace) {
Text("All chats").tag(nil as String?)
ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) }
}
Button("Library and tools") { showLibrary = true }
Button("Manage workspaces") { showWorkspaces = true }
Button("Appearance and model") { showPreferences = true }
Button("Sign out", role: .destructive) { Task { await logout() } }
} label: { Image(systemName: "line.3.horizontal.decrease.circle").frame(minWidth: 44, minHeight: 44) }
.accessibilityLabel("Chat options")
}
ToolbarItem(placement: .topBarTrailing) {
Button { Task { await store.create(workspace: workspace); if let id = store.selected?.id { path = [id] } } } label: { Image(systemName: "square.and.pencil").frame(minWidth: 44, minHeight: 44) }
.accessibilityLabel("New chat").disabled(store.busy)
}
}
.navigationDestination(for: String.self) { _ in ChatDetailView(store: store, talk: talk) }
.sheet(isPresented: $showLibrary) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
.sheet(isPresented: $showPreferences) { ChatPreferencesView(store: store) }
.sheet(isPresented: $showWorkspaces) { WorkspaceManagerView(store: store) }
.alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) {
TextField("Title", text: $title)
Button("Cancel", role: .cancel) { rename = nil }
Button("Save") { if let rename { Task { await store.patch(rename, changes: ["title": title]) } }; rename = nil }
}
.confirmationDialog("Delete this chat?", isPresented: Binding(get: { delete != nil }, set: { if !$0 { delete = nil } }), titleVisibility: .visible) {
Button("Delete chat", role: .destructive) { if let delete { Task { await store.delete(delete) } }; delete = nil }
Button("Cancel", role: .cancel) { delete = nil }
} message: { Text("This removes the conversation, not your account memory.") }
}
}
private var visible: [HopeConversation] { store.conversations.filter { workspace == nil || $0.workspaceId == workspace } }
private var pinned: [HopeConversation] { visible.filter(\.pinned).sorted { $0.pinRank < $1.pinRank } }
private var recent: [HopeConversation] { visible.filter { !$0.pinned } }
private func refresh() async { await store.load(query: query, archived: archived) }
@ViewBuilder private func rows(_ items: [HopeConversation]) -> some View {
ForEach(items) { conversation in
Button {
Task { await store.open(conversation); path = [conversation.id] }
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(conversation.title).foregroundStyle(.primary).font(.body)
if let id = conversation.workspaceId, let space = store.workspaces.first(where: { $0.id == id }) { Text(space.name).font(.caption).foregroundStyle(.secondary) }
Text(Date(timeIntervalSince1970: conversation.updatedAt), style: .date).font(.caption).foregroundStyle(.secondary)
}.frame(minHeight: 44, alignment: .leading)
}
.contextMenu {
Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } }
Button("Rename", systemImage: "pencil") { title = conversation.title; rename = conversation }
Menu("Move to workspace") {
Button("Outside workspaces") { Task { await store.patch(conversation, changes: ["workspace_id": NSNull()]) } }
ForEach(store.workspaces) { space in Button(space.name) { Task { await store.patch(conversation, changes: ["workspace_id": space.id]) } } }
}
Button(conversation.archived ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { await store.patch(conversation, changes: ["archived": !conversation.archived]) } }
Button("Delete", systemImage: "trash", role: .destructive) { delete = conversation }
}
}
}
}
struct ChatDetailView: View {
@ObservedObject var store: ChatStore
let talk: (String) async -> Void
@State private var editing: HopeMessage?
@State private var editedText = ""
@State private var follow = false
@State private var library = false
@Environment(\.scenePhase) private var scenePhase
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 24) {
if store.messages.isEmpty { ContentUnavailableView("What’s on your mind?", systemImage: "bubble.left", description: Text("Write below to begin. Text chat does not connect your microphone.")) }
if store.olderMessagesCursor != nil { Button("Load earlier messages") { follow = false; Task { await store.loadOlder() } } }
ForEach(store.messages) { message in
VStack(alignment: .leading, spacing: 8) {
HStack { Text(message.role == "user" ? "You" : "Hope").font(.subheadline.weight(.semibold)); Spacer(); if message.status != "completed" { Text(message.status.capitalized).font(.caption).foregroundStyle(.secondary) } }
RichChatMarkdown(content: message.content)
Menu {
Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = message.content }
Button("Branch from here", systemImage: "arrow.triangle.branch") { Task { await store.branch(message) } }
if message.role == "user" { Button("Edit in new branch", systemImage: "pencil") { editing = message; editedText = message.content } }
else { Button("Regenerate in new branch", systemImage: "arrow.clockwise") { Task { await store.branch(message, regenerate: true) } } }
} label: { Label("Message actions", systemImage: "ellipsis").font(.caption).frame(minHeight: 44) }
.disabled(store.busy || store.activeRun != nil)
}.id(message.id)
}
if let progress = store.progress { Label(progress, systemImage: "ellipsis").foregroundStyle(.secondary) }
Color.clear.frame(height: 1).id("chat-bottom")
}.padding(.horizontal, 20).padding(.vertical, 16).frame(maxWidth: 760)
}
.simultaneousGesture(DragGesture().onChanged { _ in follow = false })
.onChange(of: store.messages) { _, _ in if follow { proxy.scrollTo("chat-bottom", anchor: .bottom) } }
.overlay(alignment: .bottomTrailing) {
if !follow && !store.messages.isEmpty { Button("Latest", systemImage: "arrow.down") { follow = true; proxy.scrollTo("chat-bottom", anchor: .bottom) }.buttonStyle(.bordered).padding() }
}
}
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.foregroundStyle(HopeTheme.color(.ink, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.navigationTitle(store.selected?.title ?? "Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Talk", systemImage: "waveform") { if let id = store.selected?.id { Task { await talk(id) } } }.disabled(store.activeRun != nil)
Button("Export Markdown") { Task { await store.exportConversation(format: "markdown") } }
Button("Export JSON") { Task { await store.exportConversation(format: "json") } }
if let text = store.exportText { ShareLink("Share export", item: text) }
} label: { Image(systemName: "ellipsis.circle").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Chat actions")
} }
.safeAreaInset(edge: .bottom) {
VStack(alignment: .leading, spacing: 8) {
if let error = store.error { Text(error).font(.footnote).foregroundStyle(.red); if store.activeRun != nil { Button("Resume response") { store.resume() } } }
if let approval = store.approval {
Text(approval.prompt).font(.body)
HStack { Button("Deny", role: .destructive) { Task { await store.decide("deny") } }; Button("Approve") { Task { await store.decide("approve") } } }
}
if store.researchAvailable { Toggle("Research with cited sources", isOn: $store.research).font(.footnote) }
if !store.selectedAttachmentIds.isEmpty { Text("\(store.selectedAttachmentIds.count) attached files").font(.caption); Button("Remove attachments") { store.selectedAttachmentIds = [] } }
HStack(alignment: .bottom) {
Button { library = true } label: { Image(systemName: "paperclip").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Attach files")
TextField("Message Hope", text: $store.draft, axis: .vertical).lineLimit(1...7).textFieldStyle(.roundedBorder).accessibilityIdentifier("chat-draft")
if store.activeRun != nil { Button("Stop", systemImage: "stop.fill") { Task { await store.stop() } }.frame(minWidth: 44, minHeight: 44) }
else { Button { follow = true; Task { await store.send() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title).frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Send message").disabled(store.busy || store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}.padding(16).background(.regularMaterial)
}
.sheet(isPresented: $library) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
.sheet(item: $editing) { message in
NavigationStack {
Form { Section("Edit message") { TextEditor(text: $editedText).frame(minHeight: 200) }; Text("A new branch preserves the original conversation.").font(.footnote).foregroundStyle(.secondary) }
.navigationTitle("Edit in branch")
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { editing = nil } }; ToolbarItem(placement: .confirmationAction) { Button("Send") { editing = nil; Task { await store.branch(message, edited: editedText) } }.disabled(editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }
}
}
.onChange(of: scenePhase) { _, phase in if phase == .active { store.resume() } }
}
}
/// Native selectable prose and fenced code; no WebView or remote rendering.
struct RichChatMarkdown: View {
let content: String
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
if block.code {
VStack(alignment: .leading, spacing: 4) {
HStack { Text(block.language.isEmpty ? "Code" : block.language).font(.caption).foregroundStyle(.secondary); Spacer(); Button("Copy code") { UIPasteboard.general.string = block.text }.font(.caption).frame(minHeight: 44) }
ScrollView(.horizontal) { Text(block.text).font(.system(.body, design: .monospaced)).textSelection(.enabled).fixedSize(horizontal: true, vertical: false) }
}.padding(12).background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
} else {
ForEach(Array(block.text.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
if line.hasPrefix("# ") { markdown(String(line.dropFirst(2))).font(.title2.weight(.semibold)) }
else if line.hasPrefix("## ") { markdown(String(line.dropFirst(3))).font(.title3.weight(.semibold)) }
else if line.hasPrefix("### ") { markdown(String(line.dropFirst(4))).font(.headline) }
else if line.hasPrefix("- ") || line.hasPrefix("* ") { HStack(alignment: .top) { Text("•"); markdown(String(line.dropFirst(2))) } }
else if line.hasPrefix("> ") { markdown(String(line.dropFirst(2))).foregroundStyle(.secondary) }
else { markdown(line) }
}
}
}
}.textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
}
private func markdown(_ text: String) -> Text {
Text((try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text))
}
private var blocks: [(text: String, code: Bool, language: String)] {
let pieces = content.components(separatedBy: "```")
return pieces.enumerated().map { i, piece in
if i.isMultiple(of: 2) { return (piece, false, "") }
let lines = piece.components(separatedBy: "\n")
return (lines.dropFirst().joined(separator: "\n").trimmingCharacters(in: .newlines), true, lines.first ?? "")
}
}
}
struct ChatPreferencesView: View {
@ObservedObject var store: ChatStore
@Environment(\.dismiss) private var dismiss
@State private var palette = "opal"
@State private var appearance = "system"
var body: some View {
NavigationStack {
Form {
Section("Appearance") {
Picker("Palette", selection: $palette) { ForEach(["opal", "graphite", "ocean", "sage", "lavender"], id: \.self) { Text($0.capitalized).tag($0) } }
Picker("Appearance", selection: $appearance) { ForEach(["system", "light", "dark"], id: \.self) { Text($0.capitalized).tag($0) } }
Button("Save appearance") { Task { await store.savePreferences(palette: palette, appearance: appearance) } }
}
Section("Server model") {
if !store.models.isEmpty {
Picker("Model", selection: $store.model) { ForEach(store.models) { Text($0.id).tag($0.id) } }
if let levels = store.models.first(where: { $0.id == store.model })?.thinkingLevels, !levels.isEmpty { Picker("Thinking", selection: $store.thinkingLevel) { ForEach(levels, id: \.self) { Text($0.capitalized).tag($0) } } }
Button("Save model settings") { Task { await store.saveModel() } }
}
if let note = store.modelNote { Text(note).font(.footnote).foregroundStyle(.secondary) }
Text("Models are provided by your authenticated Hope server. No direct provider connection.").font(.footnote).foregroundStyle(.secondary)
}
if let error = store.error { Text(error).foregroundStyle(.red) }
}
.navigationTitle("Chat preferences")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.task { palette = store.preferences.palette; appearance = store.preferences.appearance; await store.loadModels() }
}
}
}
struct WorkspaceManagerView: View {
@ObservedObject var store: ChatStore
@Environment(\.dismiss) private var dismiss
@State private var editing: HopeWorkspace?
@State private var name = ""
@State private var instructions = ""
@State private var deleting: HopeWorkspace?
var body: some View {
NavigationStack {
Form {
Section("Workspaces") {
ForEach(store.workspaces) { space in
Button(space.name) { editing = space; name = space.name; instructions = space.instructions }
.swipeActions { Button("Delete", role: .destructive) { deleting = space } }
}
}
Section(editing == nil ? "New workspace" : "Edit workspace") {
TextField("Workspace name", text: $name)
TextField("Instructions", text: $instructions, axis: .vertical).lineLimit(3...8)
Text("Instructions guide chats. Workspaces do not isolate tool access.").font(.footnote).foregroundStyle(.secondary)
Button("Save workspace") { Task { await store.saveWorkspace(editing, name: name, instructions: instructions); editing = nil; name = ""; instructions = "" } }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if editing != nil { Button("Cancel edit") { editing = nil; name = ""; instructions = "" } }
}
}
.navigationTitle("Workspaces")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.confirmationDialog("Delete workspace?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) {
Button("Delete workspace", role: .destructive) { if let deleting { Task { await store.deleteWorkspace(deleting) } }; deleting = nil }
} message: { Text("Its chats are retained outside the workspace.") }
}
}
}
+160
View File
@@ -0,0 +1,160 @@
// Generated from design/hope-palette.v1.json v1.0.0; SHA256 bb76134be477916d776cda4259f45096fc2dcfcf873918b8703a0b716391a350.
// DO NOT EDIT. python3 scripts/generate_hope_themes.py
import SwiftUI
import UIKit
struct HopeRGB: Sendable {
let red: Double
let green: Double
let blue: Double
init(_ red: Double, _ green: Double, _ blue: Double) {
self.red = red; self.green = green; self.blue = blue
}
var uiColor: UIColor { UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: 1) }
}
enum HopeAppearance: String, CaseIterable, Sendable {
case system, light, dark
}
enum HopePalette: String, CaseIterable, Sendable {
case opal
case graphite
case ocean
case sage
case lavender
static let defaultPalette: HopePalette = .opal
var label: String {
switch self {
case .opal: return "Opal Pink"
case .graphite: return "Graphite"
case .ocean: return "Ocean"
case .sage: return "Sage"
case .lavender: return "Lavender"
}
}
}
enum HopeThemeRole: String, CaseIterable, Sendable {
case ground
case surface
case personaSurface
case ink
case quiet
case accent
case onAccent
case alert
case onAlert
case focus
case controlBorder
case separator
case selectedWash
case errorWash
}
enum HopeTheme {
static let version = "1.0.0"
static func rgb(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette, dark: Bool) -> HopeRGB {
switch palette {
case .opal:
switch role {
case .ground: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 0.9921568627450981, 0.9921568627450981)
case .surface: return dark ? HopeRGB(0.18823529411764706, 0.13725490196078433, 0.18823529411764706) : HopeRGB(1, 0.9686274509803922, 0.9803921568627451)
case .personaSurface: return dark ? HopeRGB(0.23137254901960785, 0.1568627450980392, 0.21176470588235294) : HopeRGB(1, 0.9490196078431372, 0.9686274509803922)
case .ink: return dark ? HopeRGB(1, 0.9490196078431372, 0.9686274509803922) : HopeRGB(0.18823529411764706, 0.16862745098039217, 0.25098039215686274)
case .quiet: return dark ? HopeRGB(0.796078431372549, 0.7137254901960784, 0.7803921568627451) : HopeRGB(0.4392156862745098, 0.3764705882352941, 0.4549019607843137)
case .accent: return dark ? HopeRGB(0.9411764705882353, 0.6588235294117647, 0.7843137254901961) : HopeRGB(0.6509803921568628, 0.25882352941176473, 0.4392156862745098)
case .onAccent: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.9411764705882353, 0.6588235294117647, 0.7843137254901961) : HopeRGB(0.6509803921568628, 0.25882352941176473, 0.4392156862745098)
case .controlBorder: return dark ? HopeRGB(0.796078431372549, 0.7137254901960784, 0.7803921568627451) : HopeRGB(0.4392156862745098, 0.3764705882352941, 0.4549019607843137)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.3018823529411765, 0.2509019607843137, 0.2974901960784314) : HopeRGB(0.8863529411764706, 0.8566274509803922, 0.8782745098039215)
case .selectedWash: return dark ? HopeRGB(0.2635294117647059, 0.1894117647058824, 0.24784313725490195) : HopeRGB(0.9650980392156863, 0.8976470588235295, 0.9262745098039216)
case .errorWash: return dark ? HopeRGB(0.2531764705882353, 0.17459450980392158, 0.2271764705882353) : HopeRGB(0.9740000000000001, 0.9077772549019608, 0.9245207843137255)
}
case .graphite:
switch role {
case .ground: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(0.9921568627450981, 0.9921568627450981, 0.996078431372549)
case .surface: return dark ? HopeRGB(0.1450980392156863, 0.1568627450980392, 0.1843137254901961) : HopeRGB(0.9529411764705882, 0.9568627450980393, 0.9647058823529412)
case .personaSurface: return dark ? HopeRGB(0.18823529411764706, 0.20392156862745098, 0.23921568627450981) : HopeRGB(0.9294117647058824, 0.9333333333333333, 0.9490196078431372)
case .ink: return dark ? HopeRGB(0.9529411764705882, 0.9568627450980393, 0.9686274509803922) : HopeRGB(0.15294117647058825, 0.16470588235294117, 0.2)
case .quiet: return dark ? HopeRGB(0.7372549019607844, 0.7568627450980392, 0.803921568627451) : HopeRGB(0.3686274509803922, 0.3843137254901961, 0.43137254901960786)
case .accent: return dark ? HopeRGB(0.7490196078431373, 0.788235294117647, 0.8627450980392157) : HopeRGB(0.3137254901960784, 0.34901960784313724, 0.4117647058823529)
case .onAccent: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.7490196078431373, 0.788235294117647, 0.8627450980392157) : HopeRGB(0.3137254901960784, 0.34901960784313724, 0.4117647058823529)
case .controlBorder: return dark ? HopeRGB(0.7372549019607844, 0.7568627450980392, 0.803921568627451) : HopeRGB(0.3686274509803922, 0.3843137254901961, 0.43137254901960786)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.2581960784313726, 0.2688627450980392, 0.29411764705882354) : HopeRGB(0.8409411764705882, 0.8459607843137256, 0.8576470588235294)
case .selectedWash: return dark ? HopeRGB(0.2054901960784314, 0.22, 0.2521568627450981) : HopeRGB(0.8890196078431373, 0.8960784313725491, 0.9094117647058824)
case .errorWash: return dark ? HopeRGB(0.2134901960784314, 0.19263372549019608, 0.22356862745098044) : HopeRGB(0.9307058823529412, 0.8969537254901961, 0.9100894117647059)
}
case .ocean:
switch role {
case .ground: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(0.9882352941176471, 0.996078431372549, 1)
case .surface: return dark ? HopeRGB(0.12549019607843137, 0.19215686274509805, 0.24705882352941178) : HopeRGB(0.9411764705882353, 0.9686274509803922, 0.984313725490196)
case .personaSurface: return dark ? HopeRGB(0.1568627450980392, 0.23921568627450981, 0.30196078431372547) : HopeRGB(0.9058823529411765, 0.9490196078431372, 0.9764705882352941)
case .ink: return dark ? HopeRGB(0.9294117647058824, 0.9686274509803922, 1) : HopeRGB(0.13725490196078433, 0.20392156862745098, 0.25882352941176473)
case .quiet: return dark ? HopeRGB(0.7019607843137254, 0.796078431372549, 0.8705882352941177) : HopeRGB(0.3254901960784314, 0.40784313725490196, 0.47058823529411764)
case .accent: return dark ? HopeRGB(0.592156862745098, 0.807843137254902, 0.9333333333333333) : HopeRGB(0.15294117647058825, 0.396078431372549, 0.5490196078431373)
case .onAccent: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.592156862745098, 0.807843137254902, 0.9333333333333333) : HopeRGB(0.15294117647058825, 0.396078431372549, 0.5490196078431373)
case .controlBorder: return dark ? HopeRGB(0.7019607843137254, 0.796078431372549, 0.8705882352941177) : HopeRGB(0.3254901960784314, 0.40784313725490196, 0.47058823529411764)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.2380392156862745, 0.30086274509803923, 0.35247058823529415) : HopeRGB(0.828627450980392, 0.8615686274509804, 0.8827450980392156)
case .selectedWash: return dark ? HopeRGB(0.17215686274509806, 0.25372549019607843, 0.31568627450980397) : HopeRGB(0.8623529411764705, 0.9113725490196078, 0.9407843137254902)
case .errorWash: return dark ? HopeRGB(0.19545098039215686, 0.22510431372549022, 0.28129411764705886) : HopeRGB(0.9198823529411766, 0.9077772549019608, 0.9281286274509805)
}
case .sage:
switch role {
case .ground: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(0.9882352941176471, 0.996078431372549, 0.9882352941176471)
case .surface: return dark ? HopeRGB(0.14901960784313725, 0.20392156862745098, 0.16470588235294117) : HopeRGB(0.9411764705882353, 0.9647058823529412, 0.9450980392156862)
case .personaSurface: return dark ? HopeRGB(0.18823529411764706, 0.25882352941176473, 0.21176470588235294) : HopeRGB(0.9058823529411765, 0.9411764705882353, 0.9137254901960784)
case .ink: return dark ? HopeRGB(0.9411764705882353, 0.9725490196078431, 0.9450980392156862) : HopeRGB(0.1607843137254902, 0.2196078431372549, 0.17647058823529413)
case .quiet: return dark ? HopeRGB(0.7254901960784313, 0.8117647058823529, 0.7450980392156863) : HopeRGB(0.34901960784313724, 0.4196078431372549, 0.36470588235294116)
case .accent: return dark ? HopeRGB(0.6470588235294118, 0.8313725490196079, 0.6862745098039216) : HopeRGB(0.23529411764705882, 0.4392156862745098, 0.30196078431372547)
case .onAccent: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.6470588235294118, 0.8313725490196079, 0.6862745098039216) : HopeRGB(0.23529411764705882, 0.4392156862745098, 0.30196078431372547)
case .controlBorder: return dark ? HopeRGB(0.7254901960784313, 0.8117647058823529, 0.7450980392156863) : HopeRGB(0.34901960784313724, 0.4196078431372549, 0.36470588235294116)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.25992156862745097, 0.3115294117647059, 0.2739607843137255) : HopeRGB(0.8319215686274509, 0.8603921568627451, 0.8374901960784313)
case .selectedWash: return dark ? HopeRGB(0.19882352941176473, 0.26666666666666666, 0.2168627450980392) : HopeRGB(0.8705882352941177, 0.9121568627450981, 0.8807843137254902)
case .errorWash: return dark ? HopeRGB(0.21709803921568627, 0.2359278431372549, 0.2055294117647059) : HopeRGB(0.9198823529411766, 0.9041694117647059, 0.8920501960784314)
}
case .lavender:
switch role {
case .ground: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(0.996078431372549, 0.9921568627450981, 1)
case .surface: return dark ? HopeRGB(0.1803921568627451, 0.15294117647058825, 0.23529411764705882) : HopeRGB(0.9647058823529412, 0.9490196078431372, 0.9882352941176471)
case .personaSurface: return dark ? HopeRGB(0.23137254901960785, 0.19607843137254902, 0.30196078431372547) : HopeRGB(0.9333333333333333, 0.9098039215686274, 0.9725490196078431)
case .ink: return dark ? HopeRGB(0.9686274509803922, 0.9450980392156862, 1) : HopeRGB(0.20392156862745098, 0.17647058823529413, 0.2627450980392157)
case .quiet: return dark ? HopeRGB(0.7843137254901961, 0.7294117647058823, 0.8745098039215686) : HopeRGB(0.40784313725490196, 0.3686274509803922, 0.4823529411764706)
case .accent: return dark ? HopeRGB(0.8117647058823529, 0.7058823529411765, 0.9568627450980393) : HopeRGB(0.4392156862745098, 0.3215686274509804, 0.611764705882353)
case .onAccent: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.8117647058823529, 0.7058823529411765, 0.9568627450980393) : HopeRGB(0.4392156862745098, 0.3215686274509804, 0.611764705882353)
case .controlBorder: return dark ? HopeRGB(0.7843137254901961, 0.7294117647058823, 0.8745098039215686) : HopeRGB(0.40784313725490196, 0.3686274509803922, 0.4823529411764706)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.29074509803921567, 0.263843137254902, 0.3423529411764706) : HopeRGB(0.8581960784313726, 0.8408627450980392, 0.8866666666666667)
case .selectedWash: return dark ? HopeRGB(0.24352941176470588, 0.20823529411764707, 0.3074509803921569) : HopeRGB(0.9121568627450981, 0.8862745098039215, 0.9505882352941177)
case .errorWash: return dark ? HopeRGB(0.24596078431372548, 0.18902588235294118, 0.27047058823529413) : HopeRGB(0.941529411764706, 0.8897380392156863, 0.9317364705882354)
}
}
}
static func color(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette,
appearance: HopeAppearance = .system) -> Color {
let light = rgb(role, palette: palette, dark: false).uiColor
let dark = rgb(role, palette: palette, dark: true).uiColor
switch appearance {
case .light: return Color(uiColor: light)
case .dark: return Color(uiColor: dark)
case .system:
return Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark ? dark : light
})
}
}
}