Archived
feat: add authenticated durable native Hope chat
This commit is contained in:
1 parent
74d2b63614
commit
7dcd06c911
15 files changed
+1675
-9
No files matched your search
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Hope Voice</string>
|
<string>Hope</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|||||||
@@ -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) } }
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ struct ConversationView: View {
|
|||||||
@State private var activeServerURL: String
|
@State private var activeServerURL: String
|
||||||
@State private var pendingServerURL: String?
|
@State private var pendingServerURL: String?
|
||||||
@State private var isReplacingSession = false
|
@State private var isReplacingSession = false
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 2
|
||||||
|
@StateObject private var chat: ChatStore
|
||||||
|
|
||||||
init(settings: SettingsStore, auth: AuthStore, user: AuthUser, bearerToken: String) {
|
init(settings: SettingsStore, auth: AuthStore, user: AuthUser, bearerToken: String) {
|
||||||
self.auth = auth
|
self.auth = auth
|
||||||
@@ -21,6 +22,11 @@ struct ConversationView: View {
|
|||||||
let url = settings.trimmedServerURL
|
let url = settings.trimmedServerURL
|
||||||
_activeServerURL = State(initialValue: url)
|
_activeServerURL = State(initialValue: url)
|
||||||
_session = State(initialValue: Self.makeSession(url, settings: settings, bearerToken: bearerToken, userID: user.id))
|
_session = State(initialValue: Self.makeSession(url, settings: settings, bearerToken: bearerToken, userID: user.id))
|
||||||
|
let api: ChatTransport = auth.fixture ? OfflineChatAPI() : ChatAPI(config: auth.config, token: bearerToken)
|
||||||
|
_chat = StateObject(wrappedValue: ChatStore(api: api))
|
||||||
|
#if DEBUG
|
||||||
|
if auth.fixture && !ProcessInfo.processInfo.arguments.contains("--ui-chat") { _selectedTab = State(initialValue: 0) }
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func makeSession(_ url: String, settings: SettingsStore, bearerToken: String, userID: String) -> VoiceSession {
|
private static func makeSession(_ url: String, settings: SettingsStore, bearerToken: String, userID: String) -> VoiceSession {
|
||||||
@@ -49,6 +55,14 @@ struct ConversationView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
|
NativeChatView(store: chat, talk: { id in
|
||||||
|
await session.bindConversation(id)
|
||||||
|
selectedTab = 0
|
||||||
|
}, logout: {
|
||||||
|
await auth.logout { chat.close(); await session.disconnect() }
|
||||||
|
})
|
||||||
|
.tabItem { Label("Chats", systemImage: "bubble.left.and.text.bubble.right") }
|
||||||
|
.tag(2)
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ConversationContent(settings: settings, session: session, displayName: user.displayName ?? user.username)
|
ConversationContent(settings: settings, session: session, displayName: user.displayName ?? user.username)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -56,7 +70,7 @@ struct ConversationView: View {
|
|||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Menu {
|
Menu {
|
||||||
Button("Sign out", role: .destructive) {
|
Button("Sign out", role: .destructive) {
|
||||||
Task { await auth.logout { await session.disconnect() } }
|
Task { await auth.logout { chat.close(); await session.disconnect() } }
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "person.crop.circle")
|
Image(systemName: "person.crop.circle")
|
||||||
@@ -72,7 +86,8 @@ struct ConversationView: View {
|
|||||||
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
|
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
|
||||||
.tag(1)
|
.tag(1)
|
||||||
}
|
}
|
||||||
.tint(Nearlight.rosewood)
|
.tint(HopeTheme.color(.accent, palette: HopePalette(rawValue: chat.preferences.palette) ?? .opal))
|
||||||
|
.preferredColorScheme(chat.preferences.appearance == "system" ? nil : (chat.preferences.appearance == "dark" ? .dark : .light))
|
||||||
.onChange(of: settings.textMode) { _, enabled in
|
.onChange(of: settings.textMode) { _, enabled in
|
||||||
Task { await session.applyTextMode(enabled) }
|
Task { await session.applyTextMode(enabled) }
|
||||||
}
|
}
|
||||||
@@ -80,10 +95,13 @@ struct ConversationView: View {
|
|||||||
if auth.fixture { replaceServer(value) }
|
if auth.fixture { replaceServer(value) }
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
auth.disconnectAccount = { [weak session] in await session?.disconnect() }
|
auth.disconnectAccount = { [weak session, weak chat] in chat?.close(); await session?.disconnect() }
|
||||||
session.onUnauthorized = { [weak auth, weak session] in
|
let unauthorized: () async -> Void = { [weak auth, weak session, weak chat] in
|
||||||
await auth?.logout { await session?.disconnect() }
|
await auth?.logout { chat?.close(); await session?.disconnect() }
|
||||||
}
|
}
|
||||||
|
session.onUnauthorized = unauthorized
|
||||||
|
chat.onUnauthorized = unauthorized
|
||||||
|
chat.features.onUnauthorized = unauthorized
|
||||||
}
|
}
|
||||||
.disabled(auth.isBusy || auth.token == nil)
|
.disabled(auth.isBusy || auth.token == nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ final class VoiceSession: ObservableObject {
|
|||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit chat-to-Talk navigation tears down the prior room first.
|
||||||
|
func bindConversation(_ id: String?) async {
|
||||||
|
await disconnect()
|
||||||
|
tokenClient.conversationID = id
|
||||||
|
}
|
||||||
|
|
||||||
private func handleControlEvent(_ event: ControlChannel.InboundEvent) {
|
private func handleControlEvent(_ event: ControlChannel.InboundEvent) {
|
||||||
// Restrict inbound control-event application to active lifecycle states
|
// Restrict inbound control-event application to active lifecycle states
|
||||||
// so late events from a failed/tearing-down room cannot repopulate stale
|
// so late events from a failed/tearing-down room cannot repopulate stale
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ struct TokenResponse: Decodable, Equatable {
|
|||||||
let token: String
|
let token: String
|
||||||
let room: String
|
let room: String
|
||||||
let user: User
|
let user: User
|
||||||
|
let conversationID: String?
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case token, room, user
|
||||||
|
case conversationID = "conversation_id"
|
||||||
|
}
|
||||||
|
|
||||||
struct User: Decodable, Equatable {
|
struct User: Decodable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
@@ -44,6 +49,7 @@ final class TokenClient {
|
|||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
var onUnauthorized: (() async -> Void)?
|
var onUnauthorized: (() async -> Void)?
|
||||||
let bearerToken: String
|
let bearerToken: String
|
||||||
|
var conversationID: String?
|
||||||
|
|
||||||
init(config: ServerConfig, bearerToken: String = "", session: URLSession? = nil) {
|
init(config: ServerConfig, bearerToken: String = "", session: URLSession? = nil) {
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -63,6 +69,10 @@ final class TokenClient {
|
|||||||
guard !bearerToken.isEmpty else { throw AuthError.status(401) }
|
guard !bearerToken.isEmpty else { throw AuthError.status(401) }
|
||||||
var request = URLRequest(url: config.baseURL.appendingPathComponent("token"))
|
var request = URLRequest(url: config.baseURL.appendingPathComponent("token"))
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
|
if let conversationID {
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: ["conversation_id": conversationID])
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
}
|
||||||
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
|
||||||
request.timeoutInterval = 10
|
request.timeoutInterval = 10
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import Voice
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ChatTests: XCTestCase {
|
||||||
|
func testOfflineCRUDSearchPinsArchiveMoveAndPreferences() async throws {
|
||||||
|
let api = OfflineChatAPI()
|
||||||
|
let store = ChatStore(api: api)
|
||||||
|
await store.load()
|
||||||
|
XCTAssertTrue(store.conversations.isEmpty)
|
||||||
|
await store.saveWorkspace(nil, name: "Writing", instructions: "Be concise")
|
||||||
|
let space = try XCTUnwrap(store.workspaces.first)
|
||||||
|
await store.create(workspace: space.id)
|
||||||
|
let original = try XCTUnwrap(store.selected)
|
||||||
|
store.draft = "A durable offline message"
|
||||||
|
await store.send()
|
||||||
|
XCTAssertEqual(store.messages.count, 2)
|
||||||
|
XCTAssertEqual(store.draft, "")
|
||||||
|
await store.patch(original, changes: ["title": "Weekend", "pinned": true])
|
||||||
|
XCTAssertEqual(store.selected?.title, "Weekend")
|
||||||
|
XCTAssertTrue(store.selected?.pinned == true)
|
||||||
|
await store.load(query: "durable")
|
||||||
|
XCTAssertEqual(store.conversations.count, 1)
|
||||||
|
await store.savePreferences(palette: "ocean", appearance: "dark")
|
||||||
|
XCTAssertEqual(store.preferences.palette, "ocean")
|
||||||
|
await store.deleteWorkspace(space)
|
||||||
|
XCTAssertNil(store.conversations.first?.workspaceId)
|
||||||
|
let renamed = try XCTUnwrap(store.selected)
|
||||||
|
await store.patch(renamed, changes: ["archived": true])
|
||||||
|
await store.load()
|
||||||
|
XCTAssertTrue(store.conversations.isEmpty)
|
||||||
|
await store.load(archived: true)
|
||||||
|
XCTAssertEqual(store.conversations.count, 1)
|
||||||
|
await store.delete(try XCTUnwrap(store.conversations.first))
|
||||||
|
XCTAssertNil(store.selected)
|
||||||
|
XCTAssertTrue(store.messages.isEmpty)
|
||||||
|
}
|
||||||
|
func testOfflineResearchTruthfulExportAndTeardown() async throws {
|
||||||
|
let store = ChatStore(api: OfflineChatAPI())
|
||||||
|
await store.create()
|
||||||
|
store.draft = "Export this"; await store.send()
|
||||||
|
await store.loadResearch()
|
||||||
|
XCTAssertFalse(store.researchAvailable)
|
||||||
|
await store.exportConversation(format: "markdown")
|
||||||
|
XCTAssertTrue(store.exportText?.contains("Export this") == true)
|
||||||
|
store.foreground(true)
|
||||||
|
store.foreground(false)
|
||||||
|
store.close()
|
||||||
|
XCTAssertNil(store.exportText)
|
||||||
|
XCTAssertFalse(store.research)
|
||||||
|
}
|
||||||
|
func testBranchAndRegeneratePreserveOriginal() async throws {
|
||||||
|
let store = ChatStore(api: OfflineChatAPI())
|
||||||
|
await store.create()
|
||||||
|
let original = try XCTUnwrap(store.selected)
|
||||||
|
store.draft = "Original question"; await store.send()
|
||||||
|
let user = try XCTUnwrap(store.messages.first)
|
||||||
|
await store.branch(user, edited: "Edited question")
|
||||||
|
XCTAssertNotEqual(store.selected?.id, original.id)
|
||||||
|
XCTAssertEqual(store.messages.first?.content, "Edited question")
|
||||||
|
await store.open(original)
|
||||||
|
XCTAssertEqual(store.messages.first?.content, "Original question")
|
||||||
|
let answer = try XCTUnwrap(store.messages.last)
|
||||||
|
await store.branch(answer, regenerate: true)
|
||||||
|
XCTAssertNotEqual(store.selected?.id, original.id)
|
||||||
|
XCTAssertEqual(store.messages.first?.content, "Original question")
|
||||||
|
}
|
||||||
|
func testReplayFullReplacementAndDuplicateCursor() async throws {
|
||||||
|
let store = ChatStore(api: OfflineChatAPI())
|
||||||
|
await store.create()
|
||||||
|
let id = try XCTUnwrap(store.selected?.id)
|
||||||
|
func event(_ seq: Int, _ text: String) -> ChatEvent {
|
||||||
|
ChatEvent(id: seq, name: "message", data: Data("{\"message\":{\"id\":\"m\",\"conversation_id\":\"\(id)\",\"role\":\"assistant\",\"content\":\"\(text)\",\"status\":\"streaming\",\"run_id\":\"r\",\"created_at\":1}}".utf8))
|
||||||
|
}
|
||||||
|
try store.apply(event(1, "A"), run: "r")
|
||||||
|
try store.apply(event(2, "Answer"), run: "r")
|
||||||
|
try store.apply(event(1, "Duplicate"), run: "r")
|
||||||
|
XCTAssertEqual(store.messages.count, 1)
|
||||||
|
XCTAssertEqual(store.messages.first?.content, "Answer")
|
||||||
|
store.close()
|
||||||
|
XCTAssertTrue(store.messages.isEmpty)
|
||||||
|
XCTAssertNil(store.selected)
|
||||||
|
XCTAssertEqual(store.draft, "")
|
||||||
|
}
|
||||||
|
func testSSECommentsMultilineAndFraming() throws {
|
||||||
|
var parser = SSEParser()
|
||||||
|
XCTAssertNil(parser.consume(": keepalive"))
|
||||||
|
XCTAssertNil(parser.consume("id: 12"))
|
||||||
|
XCTAssertNil(parser.consume("event: message"))
|
||||||
|
XCTAssertNil(parser.consume("data: {"))
|
||||||
|
XCTAssertNil(parser.consume("data: }"))
|
||||||
|
let result = try XCTUnwrap(parser.consume(""))
|
||||||
|
XCTAssertEqual(result.id, 12)
|
||||||
|
XCTAssertEqual(result.name, "message")
|
||||||
|
XCTAssertEqual(String(data: result.data, encoding: .utf8), "{\n}")
|
||||||
|
XCTAssertNil(parser.consume(""))
|
||||||
|
}
|
||||||
|
func testRevisionConflictRetainsDraft() async throws {
|
||||||
|
let store = ChatStore(api: OfflineChatAPI())
|
||||||
|
await store.create(); let old = try XCTUnwrap(store.selected)
|
||||||
|
await store.patch(old, changes: ["title": "First"])
|
||||||
|
store.draft = "Keep me"
|
||||||
|
await store.patch(old, changes: ["title": "Stale"])
|
||||||
|
XCTAssertEqual(store.selected?.title, "First")
|
||||||
|
XCTAssertEqual(store.draft, "Keep me")
|
||||||
|
XCTAssertNotNil(store.error)
|
||||||
|
}
|
||||||
|
func testAdvancedOfflineUploadArtifactTaskMemoryAndPrivacy() async throws {
|
||||||
|
let api = OfflineChatAPI()
|
||||||
|
let features = ChatFeatureStore(api: api)
|
||||||
|
await features.load()
|
||||||
|
XCTAssertTrue(features.enabled.contains("attachments"))
|
||||||
|
let uploaded = await features.upload(Data("Private notes".utf8), filename: "notes.txt", mime: "text/plain")
|
||||||
|
let upload = try XCTUnwrap(uploaded)
|
||||||
|
await features.preview(upload)
|
||||||
|
XCTAssertEqual(features.previewText, "Private notes")
|
||||||
|
await features.saveArtifact(nil, title: "Draft", kind: "markdown", content: "# Draft")
|
||||||
|
let item = try XCTUnwrap(features.artifacts.first)
|
||||||
|
await features.saveArtifact(item, title: "Revised", kind: "markdown", content: "# Revised")
|
||||||
|
XCTAssertEqual(features.artifacts.first?.revision, 2)
|
||||||
|
await features.saveTask(nil, name: "Reminder", schedule: "in 1 hour", prompt: "Check notes")
|
||||||
|
let task = try XCTUnwrap(features.tasks.first)
|
||||||
|
await features.taskAction(task, action: "pause")
|
||||||
|
XCTAssertEqual(features.tasks.first?.enabled, false)
|
||||||
|
let memory = try XCTUnwrap(features.memory.first)
|
||||||
|
await features.saveMemory(memory, content: "New memory")
|
||||||
|
XCTAssertEqual(features.memory.first?.content, "New memory")
|
||||||
|
await features.saveMemory(memory, content: "Stale overwrite")
|
||||||
|
XCTAssertNotNil(features.error)
|
||||||
|
XCTAssertEqual(features.memory.first?.content, "New memory")
|
||||||
|
features.close()
|
||||||
|
XCTAssertTrue(features.attachments.isEmpty)
|
||||||
|
XCTAssertTrue(features.artifacts.isEmpty)
|
||||||
|
XCTAssertTrue(features.tasks.isEmpty)
|
||||||
|
XCTAssertTrue(features.memory.isEmpty)
|
||||||
|
XCTAssertNil(features.previewText)
|
||||||
|
}
|
||||||
|
func testSharedThemeContrastAcrossPalettes() {
|
||||||
|
func luminance(_ rgb: HopeRGB) -> Double {
|
||||||
|
func channel(_ x: Double) -> Double { x <= 0.04045 ? x / 12.92 : pow((x + 0.055) / 1.055, 2.4) }
|
||||||
|
return 0.2126 * channel(rgb.red) + 0.7152 * channel(rgb.green) + 0.0722 * channel(rgb.blue)
|
||||||
|
}
|
||||||
|
for palette in HopePalette.allCases { for dark in [false, true] {
|
||||||
|
let ground = luminance(HopeTheme.rgb(.ground, palette: palette, dark: dark))
|
||||||
|
for role in [HopeThemeRole.ink, .quiet, .accent] {
|
||||||
|
let text = luminance(HopeTheme.rgb(role, palette: palette, dark: dark))
|
||||||
|
XCTAssertGreaterThanOrEqual((max(text, ground) + 0.05) / (min(text, ground) + 0.05), 4.5, "\(palette) \(role) \(dark)")
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import Voice
|
||||||
|
|
||||||
|
private final class ChatURLProtocol: URLProtocol {
|
||||||
|
static var handler: ((URLRequest) throws -> (Int, Data))?
|
||||||
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||||
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||||
|
override func startLoading() {
|
||||||
|
do {
|
||||||
|
let (status, body) = try Self.handler!(request)
|
||||||
|
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
|
||||||
|
client?.urlProtocol(self, didLoad: body); client?.urlProtocolDidFinishLoading(self)
|
||||||
|
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||||||
|
}
|
||||||
|
override func stopLoading() {}
|
||||||
|
}
|
||||||
|
final class ChatTransportTests: XCTestCase {
|
||||||
|
func testBearerSameOriginQueryAndRawUpload() async throws {
|
||||||
|
guard case .success(let config) = ServerConfig.parse("https://hope.example:9178") else { XCTFail(); return }
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.protocolClasses = [ChatURLProtocol.self]
|
||||||
|
let api = ChatAPI(config: config, token: "offline-test-bearer", session: URLSession(configuration: configuration))
|
||||||
|
ChatURLProtocol.handler = { request in
|
||||||
|
XCTAssertEqual(request.url?.host, "hope.example")
|
||||||
|
XCTAssertEqual(request.url?.scheme, "https")
|
||||||
|
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer offline-test-bearer")
|
||||||
|
XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "a & b")
|
||||||
|
return (200, Data("{\"conversations\":[],\"next_cursor\":null}".utf8))
|
||||||
|
}
|
||||||
|
_ = try await api.request("api/conversations", query: [URLQueryItem(name: "q", value: "a & b")])
|
||||||
|
ChatURLProtocol.handler = { request in
|
||||||
|
XCTAssertEqual(request.httpMethod, "POST")
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/attachments")
|
||||||
|
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Filename"), "notes.txt")
|
||||||
|
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "text/plain")
|
||||||
|
return (201, Data("{}".utf8))
|
||||||
|
}
|
||||||
|
_ = try await api.upload(data: Data("test".utf8), filename: "notes.txt", mime: "text/plain")
|
||||||
|
api.cancel(); ChatURLProtocol.handler = nil
|
||||||
|
}
|
||||||
|
func testUnauthorizedAndRedirectStatusCannotBecomeSuccess() async throws {
|
||||||
|
guard case .success(let config) = ServerConfig.parse("https://hope.example") else { XCTFail(); return }
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral; configuration.protocolClasses = [ChatURLProtocol.self]
|
||||||
|
let api = ChatAPI(config: config, token: "offline", session: URLSession(configuration: configuration))
|
||||||
|
for status in [401, 403, 302, 409] {
|
||||||
|
ChatURLProtocol.handler = { _ in (status, Data()) }
|
||||||
|
do { _ = try await api.request("api/conversations"); XCTFail("Expected rejection") }
|
||||||
|
catch ChatFailure.status(let actual) { XCTAssertEqual(actual, status) }
|
||||||
|
}
|
||||||
|
api.cancel(); ChatURLProtocol.handler = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class ChatUITests: XCTestCase {
|
||||||
|
func testNativeTextChatWithoutVoiceAndAccountPrivacy() {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat"]
|
||||||
|
app.launch()
|
||||||
|
XCTAssertTrue(app.navigationBars["Chats"].waitForExistence(timeout: 10))
|
||||||
|
app.buttons["New chat"].tap()
|
||||||
|
let field = app.textFields["chat-draft"]
|
||||||
|
XCTAssertTrue(field.waitForExistence(timeout: 10))
|
||||||
|
field.tap(); field.typeText("Native offline text")
|
||||||
|
app.buttons["Send message"].tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["Native offline text"].waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue(app.staticTexts["Offline reply"].exists)
|
||||||
|
capture(app, "Native chat")
|
||||||
|
XCTAssertFalse(app.buttons["End conversation"].exists)
|
||||||
|
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||||
|
app.buttons["Chat options"].tap()
|
||||||
|
app.buttons["Sign out"].tap()
|
||||||
|
XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10))
|
||||||
|
XCTAssertFalse(app.staticTexts["Native offline text"].exists)
|
||||||
|
}
|
||||||
|
func testOfflineAdvancedLibraryAndArtifactEditor() {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat"]
|
||||||
|
app.launch()
|
||||||
|
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
|
||||||
|
app.buttons["Chat options"].tap(); app.buttons["Library and tools"].tap()
|
||||||
|
XCTAssertTrue(app.buttons["New artifact"].waitForExistence(timeout: 10))
|
||||||
|
app.buttons["New artifact"].tap()
|
||||||
|
XCTAssertTrue(app.textFields["Title"].waitForExistence(timeout: 10))
|
||||||
|
app.textFields["Title"].tap(); app.textFields["Title"].typeText("Local artifact")
|
||||||
|
app.textViews.firstMatch.tap(); app.textViews.firstMatch.typeText("Native fixture content")
|
||||||
|
app.buttons["Save"].tap()
|
||||||
|
let artifact = app.buttons.matching(NSPredicate(format: "label CONTAINS 'Local artifact'")).firstMatch
|
||||||
|
XCTAssertTrue(artifact.waitForExistence(timeout: 10))
|
||||||
|
artifact.tap()
|
||||||
|
XCTAssertTrue(app.textViews.firstMatch.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertEqual(app.textViews.firstMatch.value as? String, "Native fixture content")
|
||||||
|
capture(app, "Native artifact")
|
||||||
|
app.buttons["Cancel"].tap()
|
||||||
|
app.buttons["Done"].tap()
|
||||||
|
}
|
||||||
|
private func capture(_ app: XCUIApplication, _ name: String) {
|
||||||
|
let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||||
|
}
|
||||||
|
func testWorkspaceNativeFixture() {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat"]
|
||||||
|
app.launch()
|
||||||
|
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
|
||||||
|
app.buttons["Chat options"].tap(); app.buttons["Manage workspaces"].tap()
|
||||||
|
let field = app.textFields["Workspace name"]
|
||||||
|
XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Writing")
|
||||||
|
app.buttons["Save workspace"].tap()
|
||||||
|
XCTAssertTrue(app.buttons["Writing"].waitForExistence(timeout: 10))
|
||||||
|
capture(app, "Native workspaces")
|
||||||
|
}
|
||||||
|
func testChatLargeTextAndPreferences() {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
|
||||||
|
app.launch()
|
||||||
|
XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10))
|
||||||
|
app.buttons["Chat options"].tap()
|
||||||
|
app.buttons["Appearance and model"].tap()
|
||||||
|
XCTAssertTrue(app.navigationBars["Chat preferences"].waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue(app.buttons["Save appearance"].exists)
|
||||||
|
capture(app, "Appearance accessibility size")
|
||||||
|
app.buttons["Done"].tap()
|
||||||
|
app.tabBars.buttons["Talk"].tap()
|
||||||
|
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
|
||||||
|
app.tabBars.buttons["Activity"].tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["No tasks yet"].waitForExistence(timeout: 15))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,19 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
11BB49F87376061BDAFB9C5D /* ChatUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */; };
|
||||||
1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */; };
|
1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */; };
|
||||||
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; };
|
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; };
|
||||||
22EA06AD769759D2223D515F /* TLSLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */; };
|
22EA06AD769759D2223D515F /* TLSLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */; };
|
||||||
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.swift */; };
|
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.swift */; };
|
||||||
|
29EF2C8C93F9A4270816D22D /* ChatFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */; };
|
||||||
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; };
|
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; };
|
||||||
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
|
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
|
||||||
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
|
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
|
||||||
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; };
|
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; };
|
||||||
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; };
|
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; };
|
||||||
|
5D37D5933765F80A283DC47B /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */; };
|
||||||
|
5E4FBAAB530CA2BEEA013A57 /* ChatModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CFD3A353D5B5E4A97D263F /* ChatModels.swift */; };
|
||||||
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.swift */; };
|
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.swift */; };
|
||||||
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */; };
|
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */; };
|
||||||
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; };
|
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; };
|
||||||
@@ -29,12 +33,16 @@
|
|||||||
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
|
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
|
||||||
A5868238CF4958D6D0C5908E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */; };
|
A5868238CF4958D6D0C5908E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */; };
|
||||||
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */; };
|
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */; };
|
||||||
|
A6F527DF9D7F76FF5B4D71D7 /* HopeTheme.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */; };
|
||||||
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; };
|
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; };
|
||||||
|
B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */; };
|
||||||
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */; };
|
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */; };
|
||||||
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */; };
|
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */; };
|
||||||
|
C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B373E4A57214C0D289536B /* ChatTests.swift */; };
|
||||||
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */; };
|
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */; };
|
||||||
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; };
|
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; };
|
||||||
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */; };
|
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */; };
|
||||||
|
D8F6FDBA1D4C62B62A81885B /* ChatViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF5F1830927F8D44C4CC89A /* ChatViews.swift */; };
|
||||||
D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6FA2A93116B237CEA712E6 /* Nearlight.swift */; };
|
D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6FA2A93116B237CEA712E6 /* Nearlight.swift */; };
|
||||||
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
|
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
|
||||||
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; };
|
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; };
|
||||||
@@ -61,6 +69,7 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = "<group>"; };
|
0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = "<group>"; };
|
||||||
|
1CF5F1830927F8D44C4CC89A /* ChatViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViews.swift; sourceTree = "<group>"; };
|
||||||
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerReplacementCoordinatorTests.swift; sourceTree = "<group>"; };
|
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerReplacementCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||||
262B5A712708421C35ED6AC0 /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = "<group>"; };
|
262B5A712708421C35ED6AC0 /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = "<group>"; };
|
||||||
26922A8D6467BD51ED45B63B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
26922A8D6467BD51ED45B63B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
@@ -68,32 +77,39 @@
|
|||||||
2E5DF207EDB787F63CB3A64D /* Voices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Voices.swift; sourceTree = "<group>"; };
|
2E5DF207EDB787F63CB3A64D /* Voices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Voices.swift; sourceTree = "<group>"; };
|
||||||
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = "<group>"; };
|
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = "<group>"; };
|
||||||
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
|
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
|
||||||
|
41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatures.swift; sourceTree = "<group>"; };
|
||||||
48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = "<group>"; };
|
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = "<group>"; };
|
||||||
5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = "<group>"; };
|
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = "<group>"; };
|
||||||
61C71C48230C683D199D6F4B /* ControlChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlChannel.swift; sourceTree = "<group>"; };
|
61C71C48230C683D199D6F4B /* ControlChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlChannel.swift; sourceTree = "<group>"; };
|
||||||
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; };
|
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; };
|
||||||
|
6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = "<group>"; };
|
||||||
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
|
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
|
||||||
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; };
|
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; };
|
||||||
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; };
|
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; };
|
||||||
96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; };
|
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; };
|
||||||
|
96CFD3A353D5B5E4A97D263F /* ChatModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModels.swift; sourceTree = "<group>"; };
|
||||||
A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = "<group>"; };
|
A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = "<group>"; };
|
||||||
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; };
|
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; };
|
||||||
|
B5B373E4A57214C0D289536B /* ChatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTests.swift; sourceTree = "<group>"; };
|
||||||
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptListView.swift; sourceTree = "<group>"; };
|
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptListView.swift; sourceTree = "<group>"; };
|
||||||
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationStateTests.swift; sourceTree = "<group>"; };
|
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationStateTests.swift; sourceTree = "<group>"; };
|
||||||
C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
|
C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
|
||||||
C22975C69240AACD3418091F /* VoiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
C22975C69240AACD3418091F /* VoiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTransportTests.swift; sourceTree = "<group>"; };
|
||||||
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nearlight.swift; sourceTree = "<group>"; };
|
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nearlight.swift; sourceTree = "<group>"; };
|
||||||
CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
||||||
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; };
|
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; };
|
||||||
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; };
|
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; };
|
||||||
DAC751BFB00B04C73D19C714 /* AuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClient.swift; sourceTree = "<group>"; };
|
DAC751BFB00B04C73D19C714 /* AuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClient.swift; sourceTree = "<group>"; };
|
||||||
|
E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUITests.swift; sourceTree = "<group>"; };
|
||||||
E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = "<group>"; };
|
E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = "<group>"; };
|
||||||
EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSLiveTests.swift; sourceTree = "<group>"; };
|
EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSLiveTests.swift; sourceTree = "<group>"; };
|
||||||
EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSession.swift; sourceTree = "<group>"; };
|
EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSession.swift; sourceTree = "<group>"; };
|
||||||
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
|
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
|
||||||
|
F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopeTheme.generated.swift; sourceTree = "<group>"; };
|
||||||
FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptDedupeTests.swift; sourceTree = "<group>"; };
|
FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptDedupeTests.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -135,6 +151,7 @@
|
|||||||
6735DFBC89D9A939C44D763A /* UITests */ = {
|
6735DFBC89D9A939C44D763A /* UITests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */,
|
||||||
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */,
|
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */,
|
||||||
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */,
|
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */,
|
||||||
);
|
);
|
||||||
@@ -184,6 +201,18 @@
|
|||||||
path = Features;
|
path = Features;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
94F63FF6C1F0CD224010E2F5 /* Chat */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */,
|
||||||
|
96CFD3A353D5B5E4A97D263F /* ChatModels.swift */,
|
||||||
|
6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */,
|
||||||
|
1CF5F1830927F8D44C4CC89A /* ChatViews.swift */,
|
||||||
|
F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */,
|
||||||
|
);
|
||||||
|
path = Chat;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
B3D0379BEBE1E5F4AA58DA2D = {
|
B3D0379BEBE1E5F4AA58DA2D = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -211,6 +240,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
262B5A712708421C35ED6AC0 /* AuthTests.swift */,
|
262B5A712708421C35ED6AC0 /* AuthTests.swift */,
|
||||||
|
B5B373E4A57214C0D289536B /* ChatTests.swift */,
|
||||||
|
C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */,
|
||||||
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */,
|
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */,
|
||||||
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */,
|
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */,
|
||||||
EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */,
|
EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */,
|
||||||
@@ -239,6 +270,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
CE49A38E40AAF659A9281B85 /* App */,
|
CE49A38E40AAF659A9281B85 /* App */,
|
||||||
|
94F63FF6C1F0CD224010E2F5 /* Chat */,
|
||||||
8DA8BEF2EE3E0F4181A94EB9 /* Features */,
|
8DA8BEF2EE3E0F4181A94EB9 /* Features */,
|
||||||
2820A418F921E18A2FA7E4FB /* LiveKit */,
|
2820A418F921E18A2FA7E4FB /* LiveKit */,
|
||||||
6B2AB34274D9B37E77178C6C /* Networking */,
|
6B2AB34274D9B37E77178C6C /* Networking */,
|
||||||
@@ -366,10 +398,15 @@
|
|||||||
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */,
|
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */,
|
||||||
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */,
|
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */,
|
||||||
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */,
|
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */,
|
||||||
|
29EF2C8C93F9A4270816D22D /* ChatFeatures.swift in Sources */,
|
||||||
|
5E4FBAAB530CA2BEEA013A57 /* ChatModels.swift in Sources */,
|
||||||
|
5D37D5933765F80A283DC47B /* ChatStore.swift in Sources */,
|
||||||
|
D8F6FDBA1D4C62B62A81885B /* ChatViews.swift in Sources */,
|
||||||
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */,
|
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */,
|
||||||
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */,
|
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */,
|
||||||
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */,
|
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */,
|
||||||
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
|
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
|
||||||
|
A6F527DF9D7F76FF5B4D71D7 /* HopeTheme.generated.swift in Sources */,
|
||||||
F09DD789D07F49C46A659A4F /* KeychainTokenStore.swift in Sources */,
|
F09DD789D07F49C46A659A4F /* KeychainTokenStore.swift in Sources */,
|
||||||
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */,
|
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */,
|
||||||
93F179C1E302E4868B9AFC71 /* LoginView.swift in Sources */,
|
93F179C1E302E4868B9AFC71 /* LoginView.swift in Sources */,
|
||||||
@@ -394,6 +431,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
11BB49F87376061BDAFB9C5D /* ChatUITests.swift in Sources */,
|
||||||
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */,
|
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */,
|
||||||
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */,
|
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */,
|
||||||
);
|
);
|
||||||
@@ -404,6 +442,8 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */,
|
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */,
|
||||||
|
C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */,
|
||||||
|
B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */,
|
||||||
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */,
|
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */,
|
||||||
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */,
|
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */,
|
||||||
22EA06AD769759D2223D515F /* TLSLiveTests.swift in Sources */,
|
22EA06AD769759D2223D515F /* TLSLiveTests.swift in Sources */,
|
||||||
|
|||||||
+51
-1
@@ -1,11 +1,61 @@
|
|||||||
# Project status and handoff
|
# Project status and handoff
|
||||||
|
|
||||||
Last updated: 2026-09-19 (documentation review; no new runtime qualification).
|
Last updated: 2026-10-03 (native chat offline qualification; no live chat/deployment qualification).
|
||||||
|
|
||||||
Reusable implementation, testing, device and deployment lessons are in
|
Reusable implementation, testing, device and deployment lessons are in
|
||||||
[ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install
|
[ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install
|
||||||
results below are historical evidence, not a September 19 health check.
|
results below are historical evidence, not a September 19 health check.
|
||||||
|
|
||||||
|
## Unified native Hope chat — 2026-10-03
|
||||||
|
|
||||||
|
The explicit unified-parity request supersedes historical no-model-picker notes.
|
||||||
|
Native **Chats** is now the default authenticated shell, alongside unchanged Talk
|
||||||
|
and Activity access. HTTP text chat uses the same verified account/bearer and
|
||||||
|
pinned, ephemeral, redirect-denying networking; it requires neither LiveKit nor
|
||||||
|
microphone capture. Durable server conversations, search, paging, workspaces,
|
||||||
|
pins, archive, move, rename and confirmed delete are supported. Branch/edit/
|
||||||
|
regenerate preserve original history. SSE full-message replacements deduplicate
|
||||||
|
monotonic replay IDs, expose progress/approval/stop and explicit resume after
|
||||||
|
interruption. Foreground metadata sync is account-scoped, five-second, cancels on
|
||||||
|
background/logout, and preserves drafts/read position. Text selection, fenced-code
|
||||||
|
copy, headings/lists/links use native rendering (no remote WebView).
|
||||||
|
|
||||||
|
The backend-generated semantic Opal/Graphite/Ocean/Sage/Lavender artifact is
|
||||||
|
consumed by native chat; appearance and palette persist through authenticated
|
||||||
|
preferences. Model/thinking controls use only `/api/settings/model`, never a
|
||||||
|
direct upstream connection. Chat → Talk binds `/token` to the selected durable
|
||||||
|
conversation after existing room teardown; nil legacy bootstrap stays supported.
|
||||||
|
Keychain origin scope, auth verification/logout sequencing, exact TLS pins and
|
||||||
|
existing voice lifecycle invariants were not weakened.
|
||||||
|
|
||||||
|
Advanced UI follows the new `CHAT_FEATURES_CONTRACT`: capability-gated private
|
||||||
|
Files/Photos uploads (8 MiB client cap; up to eight selected IDs), in-memory
|
||||||
|
text/image/PDF preview, artifacts with revision edit/history/text share, scheduled
|
||||||
|
task create/edit/pause/resume/run/delete and revision-aware memory controls.
|
||||||
|
Uploads do not automatically send. Task execution is server-owned; there is no
|
||||||
|
APNs/push or task output history claim. Images/research show only after explicit
|
||||||
|
server qualification. Memory changes may require a later Hermes session.
|
||||||
|
Connectors and fabricated integration switches are not shown. Advanced data,
|
||||||
|
previews/drafts, replay tasks and exports clear during account teardown.
|
||||||
|
|
||||||
|
Offline verification: initial 66 unit + 2 chat UI passed; expanded 67 unit + 10 UI
|
||||||
|
(7 existing voice + 3 chat) passed at `/tmp/hope-chat-final-tests.xcresult`.
|
||||||
|
Extra HTTP transport coverage then passed 69 unit + 3 chat UI at
|
||||||
|
`/tmp/hope-chat-qualified-tests.xcresult`. An earlier artifact UI run failed an
|
||||||
|
exact row label assertion (native label includes revision/kind); predicate fixed,
|
||||||
|
failed evidence retained at `/tmp/hope-chat-advanced-tests.xcresult`.
|
||||||
|
Final current-source run passed **70 unit + 4 chat UI tests**, zero failures,
|
||||||
|
`/tmp/hope-chat-final-qualified.xcresult` and `/tmp/hope-chat-final-qualified.log`.
|
||||||
|
Retained Chat, Workspaces, Appearance (accessibility XXXL), and Artifact screenshots
|
||||||
|
were exported to `/tmp/hope-chat-screenshots/` and visually inspected. Screens use
|
||||||
|
native navigation/forms and readable Opal tint; appearance title truncates at XXXL
|
||||||
|
as standard large-title behavior, controls remain reachable/scrollable.
|
||||||
|
Signed generic iPhone build passed at `/tmp/hope-chat-phone-build.log`, artifact
|
||||||
|
`/tmp/hope-chat-phone-build/Build/Products/Debug-iphoneos/Voice.app`. Final current
|
||||||
|
source signed build passed again at `/tmp/hope-chat-phone-final-build.log`. No installation,
|
||||||
|
production requests, backend deployment or physical speech qualification occurred.
|
||||||
|
Opt-in live TLS cases return without probes in offline suite and are not TLS proof.
|
||||||
|
|
||||||
## Approved Opal pink redesign — 2026-10-02
|
## Approved Opal pink redesign — 2026-10-02
|
||||||
|
|
||||||
User approved the Opal HTML direction with the existing square reactive persona.
|
User approved the Opal HTML direction with the existing square reactive persona.
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ targets:
|
|||||||
info:
|
info:
|
||||||
path: Resources/Info.plist
|
path: Resources/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Hope Voice
|
CFBundleDisplayName: Hope
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
NSMicrophoneUsageDescription: "Hope Voice uses the microphone for live voice conversation."
|
NSMicrophoneUsageDescription: "Hope Voice uses the microphone for live voice conversation."
|
||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
|
|||||||
Reference in new issue
Block a user