488 lines
35 KiB
Swift
488 lines
35 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
|
||
private var capabilityGeneration = 0
|
||
private var loadGeneration = 0
|
||
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 {
|
||
guard !closed else { throw CancellationError() }
|
||
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)
|
||
}
|
||
@discardableResult
|
||
func refreshCapabilities() async -> Bool {
|
||
guard !closed else { return false }
|
||
capabilityGeneration += 1
|
||
let generation = capabilityGeneration
|
||
enabled = [] // Never retain image qualification while a refresh is pending or failed.
|
||
do {
|
||
let caps: FeatureCapabilities = try await call("api/capabilities")
|
||
guard generation == capabilityGeneration else { return false }
|
||
enabled = Set(caps.features.filter { $0.value.enabled }.map(\.key))
|
||
return true
|
||
} catch {
|
||
guard generation == capabilityGeneration else { return false }
|
||
report(error); return false
|
||
}
|
||
}
|
||
func load() async {
|
||
loadGeneration += 1
|
||
let generation = loadGeneration
|
||
guard await refreshCapabilities() else { return }
|
||
let capabilities = capabilityGeneration
|
||
do {
|
||
if enabled.contains("attachments") {
|
||
struct Page: Decodable { let attachments: [ChatAttachment] }
|
||
let page: Page = try await call("api/attachments")
|
||
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
|
||
attachments = page.attachments
|
||
} else { attachments = [] }
|
||
if enabled.contains("artifacts") {
|
||
struct Page: Decodable { let artifacts: [ChatArtifact] }
|
||
let page: Page = try await call("api/artifacts")
|
||
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
|
||
artifacts = page.artifacts
|
||
} else { artifacts = [] }
|
||
if enabled.contains("scheduled_tasks") {
|
||
struct Page: Decodable { let tasks: [ScheduledChatTask] }
|
||
let page: Page = try await call("api/tasks")
|
||
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
|
||
tasks = page.tasks
|
||
} else { tasks = [] }
|
||
if enabled.contains("memory_controls") {
|
||
struct Page: Decodable { let sections: [ChatMemory] }
|
||
let page: Page = try await call("api/memory")
|
||
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
|
||
memory = page.sections
|
||
} else { memory = [] }
|
||
error = nil
|
||
} catch { if generation == loadGeneration { report(error) } }
|
||
}
|
||
static func blockedReason(_ item: ChatAttachment, imageInput: Bool) -> String? {
|
||
if ["failed", "encrypted", "unavailable"].contains(item.extractionStatus) {
|
||
return "This file cannot be used: \(item.extractionStatus). Preview it or upload a readable copy."
|
||
}
|
||
guard ["ready", "truncated", "image"].contains(item.extractionStatus) else {
|
||
return "This file is not ready to use. Refresh and try again."
|
||
}
|
||
if item.mime.hasPrefix("image/") && !imageInput { return "Image input is not available for the current model. Choose a text file or PDF." }
|
||
return nil
|
||
}
|
||
func validatedAttachment(_ item: ChatAttachment) async -> ChatAttachment? {
|
||
guard await refreshCapabilities(), enabled.contains("attachments") else {
|
||
if !closed { error = "Private files are unavailable. Refresh and try again." }; return nil
|
||
}
|
||
let generation = capabilityGeneration
|
||
do {
|
||
struct Envelope: Decodable { let attachment: ChatAttachment }
|
||
let result: Envelope = try await call("api/attachments/\(item.id)")
|
||
guard generation == capabilityGeneration else { return nil }
|
||
if let reason = Self.blockedReason(result.attachment, imageInput: enabled.contains("image_input")) { error = reason; return nil }
|
||
if let i = attachments.firstIndex(where: { $0.id == item.id }) { attachments[i] = result.attachment }
|
||
error = nil; return result.attachment
|
||
} catch {
|
||
guard !closed, generation == capabilityGeneration else { return nil }
|
||
if case ChatFailure.status(404) = error { self.error = "This file is no longer available. Refresh your files." }
|
||
else { report(error) }
|
||
return nil
|
||
}
|
||
}
|
||
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, !closed else { return nil }
|
||
guard await refreshCapabilities(), enabled.contains("attachments"), !busy else { return nil }
|
||
if mime.hasPrefix("image/"), !enabled.contains("image_input") { error = "Image input is unavailable for the current model. Choose a text file or PDF."; 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 {
|
||
guard !closed else { return }
|
||
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, repeatCount: Int? = nil, paused: Bool = false) async {
|
||
do {
|
||
struct Result: Decodable { let task: ScheduledChatTask }
|
||
var body: [String: Any] = ["name": name, "schedule": schedule, "prompt": prompt]
|
||
if let repeatCount { guard repeatCount > 0 else { error = "Repeat count must be a positive whole number."; return }; body["repeat"] = repeatCount }
|
||
if item == nil { body["paused"] = paused }
|
||
let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: body)
|
||
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 latestMemory(_ item: ChatMemory) async -> ChatMemory? {
|
||
do {
|
||
struct Page: Decodable { let sections: [ChatMemory] }
|
||
let page: Page = try await call("api/memory")
|
||
guard let latest = page.sections.first(where: { $0.section == item.section }) else { throw ChatFailure.invalidResponse }
|
||
memory = page.sections; error = nil; return latest
|
||
} catch { report(error); return nil }
|
||
}
|
||
@discardableResult
|
||
func saveMemory(_ item: ChatMemory, content: String) async -> Bool {
|
||
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; return true
|
||
} catch {
|
||
if case ChatFailure.status(409) = error { self.error = "Memory changed on the server. Compare the latest version, then explicitly accept its revision before saving your draft." }
|
||
else { report(error) }
|
||
return false
|
||
}
|
||
}
|
||
func close() { closed = true; epoch += 1; capabilityGeneration += 1; loadGeneration += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil }
|
||
}
|
||
|
||
enum ChatLibraryDestination { case files, tasks, memory }
|
||
|
||
struct ChatLibraryView: View {
|
||
@ObservedObject var store: ChatFeatureStore
|
||
var initialDestination: ChatLibraryDestination = .files
|
||
var selectedAttachmentIds: Set<String> = []
|
||
var onUseAttachment: ((ChatAttachment) async -> Bool)?
|
||
var onRemoveAttachment: ((String) -> Void)?
|
||
@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 repeatCount = ""
|
||
@State private var createPaused = true
|
||
@State private var usingAttachment = false
|
||
@State private var feedback: String?
|
||
@State private var latestMemory: ChatMemory?
|
||
@State private var acceptedMemory: ChatMemory?
|
||
@State private var memoryWorking = false
|
||
@State private var memoryRequiresReview = false
|
||
@State private var confirmingClear = false
|
||
@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 (initialDestination == .files && !store.enabled.contains("attachments") && !store.enabled.contains("artifacts")) || (initialDestination == .tasks && !store.enabled.contains("scheduled_tasks")) || (initialDestination == .memory && !store.enabled.contains("memory_controls")) {
|
||
ContentUnavailableView("Not available on this server", systemImage: "lock", description: Text("These features must be enabled for your account by the Hope server."))
|
||
}
|
||
if initialDestination == .files && store.enabled.contains("attachments") {
|
||
Section("Private files") {
|
||
Text("Upload private files, then use them in your next message. Nothing is sent automatically.").font(.footnote).foregroundStyle(.secondary)
|
||
if let feedback { Text(feedback).font(.footnote) }
|
||
if store.busy { ProgressView("Uploading private file…") }
|
||
Button("Upload file", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy)
|
||
if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Upload photo", systemImage: "photo") }.disabled(store.busy) }
|
||
ForEach(store.attachments) { item in
|
||
VStack(alignment: .leading) {
|
||
HStack {
|
||
Text(item.filename).font(.body)
|
||
Spacer()
|
||
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)
|
||
if let reason = ChatFeatureStore.blockedReason(item, imageInput: store.enabled.contains("image_input")) {
|
||
Text(reason).font(.footnote).foregroundStyle(.secondary)
|
||
}
|
||
if selectedAttachmentIds.contains(item.id) {
|
||
Label("Queued for your next message", systemImage: "checkmark.circle.fill").font(.footnote)
|
||
Button("Remove from message") { onRemoveAttachment?(item.id) }
|
||
} else {
|
||
Button("Use in conversation", systemImage: "arrow.up.message") {
|
||
Task {
|
||
usingAttachment = true; defer { usingAttachment = false }
|
||
guard let validated = await store.validatedAttachment(item) else { return }
|
||
guard let onUseAttachment else { store.error = "Open Library from a conversation to use this file."; return }
|
||
if await onUseAttachment(validated) { dismiss() }
|
||
}
|
||
}.disabled(usingAttachment || store.busy || onUseAttachment == nil || ChatFeatureStore.blockedReason(item, imageInput: store.enabled.contains("image_input")) != nil)
|
||
.accessibilityHint("Queues this private file in your draft. Does not send a message.")
|
||
}
|
||
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); if store.error == nil { onRemoveAttachment?(item.id) } } } }
|
||
}
|
||
Text("Select up to 8 files for the next message. Uploading alone does not send them.").font(.footnote).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if initialDestination == .files && store.enabled.contains("artifacts") {
|
||
Section("Saved drafts") {
|
||
Text("Documents you save here are separate from conversation history.").font(.footnote).foregroundStyle(.secondary)
|
||
Button("New saved draft", 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 initialDestination == .tasks && 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 = ""; repeatCount = ""; createPaused = true; 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 ?? ""; repeatCount = item.repeat.map(String.init) ?? ""; taskEditor = true }.frame(minHeight: 44)
|
||
Text("\(item.enabled == true ? "Active" : item.enabled == false ? "Paused" : "Status not provided") · \(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 initialDestination == .memory && store.enabled.contains("memory_controls") {
|
||
Section("Durable account memory") {
|
||
Text("Guidance shapes future replies. About you stores lasting preferences and personal context. Neither is your conversation history.").font(.footnote).foregroundStyle(.secondary)
|
||
ForEach(store.memory) { item in Button(item.section == "user" ? "About you" : "Guidance") { memory = item; memoryContent = item.content; latestMemory = nil; acceptedMemory = nil; memoryRequiresReview = false } }
|
||
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(initialDestination == .files ? "Library" : initialDestination == .tasks ? "Scheduled tasks" : "Memory")
|
||
.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" }
|
||
if let uploaded = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime) { feedback = "\(uploaded.filename) uploaded privately. Tap Use in conversation to queue it." }
|
||
} 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) { if await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") != nil { feedback = "Photo uploaded privately. Tap Use in conversation to queue it." } } 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 memoryForm(item) }
|
||
.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 saved draft" : "Edit saved draft")
|
||
.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 func memoryForm(_ item: ChatMemory) -> some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section("Your local draft") {
|
||
TextEditor(text: $memoryContent).frame(minHeight: 220).accessibilityLabel("Memory draft").disabled(memoryWorking)
|
||
Text("Your draft stays here while you compare server changes. Saving affects future runs; existing sessions may retain earlier memory.").font(.footnote).foregroundStyle(.secondary)
|
||
}
|
||
Section("Revision review") {
|
||
Button("Compare latest server version") {
|
||
Task {
|
||
memoryWorking = true; defer { memoryWorking = false }
|
||
if let value = await store.latestMemory(item) { latestMemory = value; acceptedMemory = nil; memoryRequiresReview = true }
|
||
}
|
||
}.disabled(memoryWorking)
|
||
if let latestMemory {
|
||
Text(latestMemory.revision == item.revision ? "The server revision matches the version you opened." : "The server has a newer revision. Review it before overwriting with your draft.").font(.footnote)
|
||
Text(latestMemory.content.isEmpty ? "This section is empty on the server." : latestMemory.content).textSelection(.enabled)
|
||
Button("Accept this revision for my next save") { acceptedMemory = latestMemory; memoryRequiresReview = false }
|
||
.disabled(acceptedMemory?.revision == latestMemory.revision)
|
||
if acceptedMemory != nil { Text("Revision accepted. Saving will write your local draft, not the server text.").font(.footnote) }
|
||
}
|
||
}
|
||
Section {
|
||
Button("Clear this memory section", role: .destructive) { confirmingClear = true }.disabled(memoryWorking || memoryRequiresReview)
|
||
Text("Clearing removes only this durable section, not chats or all account data.").font(.footnote).foregroundStyle(.secondary)
|
||
}
|
||
if let error = store.error { Text(error).foregroundStyle(.red) }
|
||
}
|
||
.navigationTitle(item.section == "user" ? "About you" : "Guidance")
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } }
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("Save") {
|
||
if memoryContent.isEmpty { confirmingClear = true }
|
||
else { Task { await writeMemory(acceptedMemory ?? item, content: memoryContent) } }
|
||
}.disabled(memoryWorking || memoryRequiresReview)
|
||
}
|
||
}
|
||
.confirmationDialog("Clear this memory section?", isPresented: $confirmingClear, titleVisibility: .visible) {
|
||
Button("Clear section", role: .destructive) { Task { await writeMemory(acceptedMemory ?? item, content: "") } }
|
||
} message: { Text("This removes durable memory only. Conversation history is unchanged.") }
|
||
}
|
||
}
|
||
private func writeMemory(_ base: ChatMemory, content: String) async {
|
||
memoryWorking = true; defer { memoryWorking = false }
|
||
if await store.saveMemory(base, content: content) { memory = nil }
|
||
else if store.error?.contains("Memory changed") == true { memoryRequiresReview = true; acceptedMemory = nil }
|
||
}
|
||
private var taskForm: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section("Task") {
|
||
TextField("Task name", text: $name)
|
||
TextField("Schedule", text: $schedule).autocorrectionDisabled().textInputAutocapitalization(.never)
|
||
Text("Enter a schedule accepted by your server, for example cron: 0 9 * * *. The server validates it; timezone and next run are not inferred here.").font(.footnote).foregroundStyle(.secondary)
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("Instructions").font(.subheadline.weight(.semibold))
|
||
TextEditor(text: $prompt).frame(minHeight: 150).accessibilityLabel("Task instructions")
|
||
Text("Tell Hope what to do when this task runs. For example: review my plans and suggest one practical next step.").font(.footnote).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
Section("Options") {
|
||
TextField("Repeat count (optional)", text: $repeatCount).keyboardType(.numberPad)
|
||
Text("Leave blank for the server default, or enter a positive number of executions.").font(.footnote).foregroundStyle(.secondary)
|
||
if task == nil { Toggle("Create paused", isOn: $createPaused) }
|
||
Text("Tasks execute on your Hope server. This app does not deliver push notifications or show task output history.").font(.footnote).foregroundStyle(.secondary)
|
||
}
|
||
if let error = store.error { Text(error).foregroundStyle(.red) }
|
||
}
|
||
.navigationTitle(task == nil ? "New scheduled task" : "Edit task")
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } }
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("Save") { Task {
|
||
let trimmed = repeatCount.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !trimmed.isEmpty && (Int(trimmed) == nil || (Int(trimmed) ?? 0) <= 0) { store.error = "Repeat count must be a positive whole number."; return }
|
||
await store.saveTask(task, name: name, schedule: schedule, prompt: prompt, repeatCount: Int(trimmed), paused: createPaused)
|
||
if store.error == nil { taskEditor = false }
|
||
} }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || schedule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || prompt.trimmingCharacters(in: .whitespacesAndNewlines).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) } }
|
||
}
|