Files

488 lines
35 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) } }
}