Archived
299 lines
23 KiB
Swift
299 lines
23 KiB
Swift
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?
|
||
@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<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 {
|
||
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<String>
|
||
@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..<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) } }
|
||
}
|