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 = [] @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? @Published var previewAttachment: ChatAttachment? @Published var previewLoading = false @Published var previewError: String? 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(_ 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 { previewAttachment = attachment previewLoading = true previewError = nil previewPDF = nil; previewText = nil; previewImage = nil defer { if previewAttachment?.id == attachment.id { previewLoading = false } } do { let current = epoch let data = try await api.request("api/attachments/\(attachment.id)/content", method: "GET", body: nil, query: []) guard current == epoch, !closed, previewAttachment?.id == attachment.id else { return } if attachment.mime.hasPrefix("image/") { guard let image = UIImage(data: data) else { throw ChatFailure.invalidResponse } previewImage = image } else if attachment.mime == "application/pdf" { guard PDFDocument(data: data) != nil else { throw ChatFailure.invalidResponse } previewPDF = data } else if let text = String(data: data, encoding: .utf8) { previewText = text } else { throw ChatFailure.invalidResponse } } catch is CancellationError { return } catch { guard previewAttachment?.id == attachment.id else { return } previewError = "Couldn’t open \(attachment.filename). Try again." } } func clearPreview() { previewAttachment = nil; previewLoading = false; previewError = nil previewText = nil; previewImage = nil; previewPDF = nil } 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 = []; clearPreview(); error = nil } } struct ChatLibraryView: View { @ObservedObject var store: ChatFeatureStore @Binding var selectedAttachmentIds: Set @Environment(\.dismiss) private var dismiss @State private var importing = false @State private var photo: PhotosPickerItem? @State private var showingPreview = false @State private var loadingCapabilities = true @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 loadingCapabilities { ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown.")) } else if store.enabled.isEmpty { ContentUnavailableView("Library features aren’t available", systemImage: "lock", description: Text("Your server has not enabled private files, documents, schedules, or memory controls for this account.")) } 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) } else { store.error = "Select up to 8 files for one message." } } 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") { showingPreview = true; Task { await store.preview(item) } }.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(); loadingCapabilities = false } .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, onDismiss: { store.clearPreview() }) { NavigationStack { ScrollView { if store.previewLoading { ProgressView("Opening private file…").frame(maxWidth: .infinity, minHeight: 200) } else if let error = store.previewError { ContentUnavailableView("Couldn’t open file", systemImage: "exclamationmark.triangle", description: Text(error)) if let item = store.previewAttachment { Button("Try again") { Task { await store.preview(item) } } } } else if let pdf = store.previewPDF { PrivatePDFPreview(data: pdf).frame(minHeight: 500) } else if let image = store.previewImage { Image(uiImage: image).resizable().scaledToFit().padding() } else if let text = store.previewText { Text(text).textSelection(.enabled).padding() } } .navigationTitle(store.previewAttachment?.filename ?? "Private preview") .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showingPreview = false } } } } } .sheet(isPresented: $artifactEditor) { artifactForm } .sheet(isPresented: $taskEditor) { taskForm } .sheet(item: $memory) { item in NavigationStack { Form { Section("Memory") { TextEditor(text: $memoryContent).frame(minHeight: 260).accessibilityLabel("Memory content") } Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote) if let error = store.error { Text(error).foregroundStyle(.red) } } .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 { let file = PrivateChatExport(data: Data(content.utf8), filename: "Hope-artifact-\(artifact.id).\(kind == "markdown" ? "md" : "txt")", type: .plainText) ShareLink("Export file", item: file, preview: SharePreview(file.filename)) if artifact.revision > 1 { Menu("View earlier revision") { ForEach(1.. 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) } } }