feat: add authenticated durable native Hope chat
This commit is contained in:
1 parent
74d2b63614
commit
7dcd06c911
15 files changed
+1675
-9
No files matched your search
@@ -0,0 +1,282 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct NativeChatView: View {
|
||||
@ObservedObject var store: ChatStore
|
||||
let talk: (String) async -> Void
|
||||
let logout: () async -> Void
|
||||
@State private var query = ""
|
||||
@State private var archived = false
|
||||
@State private var workspace: String?
|
||||
@State private var path: [String] = []
|
||||
@State private var rename: HopeConversation?
|
||||
@State private var delete: HopeConversation?
|
||||
@State private var title = ""
|
||||
@State private var showPreferences = false
|
||||
@State private var showWorkspaces = false
|
||||
@State private var showLibrary = false
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
List {
|
||||
if let error = store.error { Section { Label(error, systemImage: "exclamationmark.circle").foregroundStyle(.red); Button("Retry") { Task { await refresh() } } } }
|
||||
if store.searching && store.conversations.isEmpty { ProgressView("Loading chats") }
|
||||
if store.conversations.isEmpty && !store.searching {
|
||||
ContentUnavailableView(archived ? "No archived chats" : "A place to think", systemImage: "bubble.left.and.bubble.right", description: Text(archived ? "Archived chats appear here." : "Start a chat. Your conversations stay with your account, independently of Talk."))
|
||||
}
|
||||
if !pinned.isEmpty { Section("Pinned") { rows(pinned) } }
|
||||
Section(archived ? "Archived" : "Recent chats") { rows(recent) }
|
||||
if store.nextCursor != nil { Button("Load more") { Task { await store.load(query: query, archived: archived, more: true) } } }
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
|
||||
.navigationTitle("Chats")
|
||||
.searchable(text: $query, prompt: "Search chats and messages")
|
||||
.task(id: query + String(archived)) {
|
||||
do { try await Task.sleep(for: .milliseconds(250)); try Task.checkCancellation(); await refresh() } catch {}
|
||||
}
|
||||
.refreshable { await refresh() }
|
||||
.onAppear { store.foreground(scenePhase == .active) }
|
||||
.onChange(of: scenePhase) { _, phase in store.foreground(phase == .active) }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Menu {
|
||||
Toggle("Show archived", isOn: $archived)
|
||||
Picker("Workspace", selection: $workspace) {
|
||||
Text("All chats").tag(nil as String?)
|
||||
ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) }
|
||||
}
|
||||
Button("Library and tools") { showLibrary = true }
|
||||
Button("Manage workspaces") { showWorkspaces = true }
|
||||
Button("Appearance and model") { showPreferences = true }
|
||||
Button("Sign out", role: .destructive) { Task { await logout() } }
|
||||
} label: { Image(systemName: "line.3.horizontal.decrease.circle").frame(minWidth: 44, minHeight: 44) }
|
||||
.accessibilityLabel("Chat options")
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { Task { await store.create(workspace: workspace); if let id = store.selected?.id { path = [id] } } } label: { Image(systemName: "square.and.pencil").frame(minWidth: 44, minHeight: 44) }
|
||||
.accessibilityLabel("New chat").disabled(store.busy)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: String.self) { _ in ChatDetailView(store: store, talk: talk) }
|
||||
.sheet(isPresented: $showLibrary) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
|
||||
.sheet(isPresented: $showPreferences) { ChatPreferencesView(store: store) }
|
||||
.sheet(isPresented: $showWorkspaces) { WorkspaceManagerView(store: store) }
|
||||
.alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) {
|
||||
TextField("Title", text: $title)
|
||||
Button("Cancel", role: .cancel) { rename = nil }
|
||||
Button("Save") { if let rename { Task { await store.patch(rename, changes: ["title": title]) } }; rename = nil }
|
||||
}
|
||||
.confirmationDialog("Delete this chat?", isPresented: Binding(get: { delete != nil }, set: { if !$0 { delete = nil } }), titleVisibility: .visible) {
|
||||
Button("Delete chat", role: .destructive) { if let delete { Task { await store.delete(delete) } }; delete = nil }
|
||||
Button("Cancel", role: .cancel) { delete = nil }
|
||||
} message: { Text("This removes the conversation, not your account memory.") }
|
||||
}
|
||||
}
|
||||
private var visible: [HopeConversation] { store.conversations.filter { workspace == nil || $0.workspaceId == workspace } }
|
||||
private var pinned: [HopeConversation] { visible.filter(\.pinned).sorted { $0.pinRank < $1.pinRank } }
|
||||
private var recent: [HopeConversation] { visible.filter { !$0.pinned } }
|
||||
private func refresh() async { await store.load(query: query, archived: archived) }
|
||||
@ViewBuilder private func rows(_ items: [HopeConversation]) -> some View {
|
||||
ForEach(items) { conversation in
|
||||
Button {
|
||||
Task { await store.open(conversation); path = [conversation.id] }
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(conversation.title).foregroundStyle(.primary).font(.body)
|
||||
if let id = conversation.workspaceId, let space = store.workspaces.first(where: { $0.id == id }) { Text(space.name).font(.caption).foregroundStyle(.secondary) }
|
||||
Text(Date(timeIntervalSince1970: conversation.updatedAt), style: .date).font(.caption).foregroundStyle(.secondary)
|
||||
}.frame(minHeight: 44, alignment: .leading)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } }
|
||||
Button("Rename", systemImage: "pencil") { title = conversation.title; rename = conversation }
|
||||
Menu("Move to workspace") {
|
||||
Button("Outside workspaces") { Task { await store.patch(conversation, changes: ["workspace_id": NSNull()]) } }
|
||||
ForEach(store.workspaces) { space in Button(space.name) { Task { await store.patch(conversation, changes: ["workspace_id": space.id]) } } }
|
||||
}
|
||||
Button(conversation.archived ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { await store.patch(conversation, changes: ["archived": !conversation.archived]) } }
|
||||
Button("Delete", systemImage: "trash", role: .destructive) { delete = conversation }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatDetailView: View {
|
||||
@ObservedObject var store: ChatStore
|
||||
let talk: (String) async -> Void
|
||||
@State private var editing: HopeMessage?
|
||||
@State private var editedText = ""
|
||||
@State private var follow = false
|
||||
@State private var library = false
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 24) {
|
||||
if store.messages.isEmpty { ContentUnavailableView("What’s on your mind?", systemImage: "bubble.left", description: Text("Write below to begin. Text chat does not connect your microphone.")) }
|
||||
if store.olderMessagesCursor != nil { Button("Load earlier messages") { follow = false; Task { await store.loadOlder() } } }
|
||||
ForEach(store.messages) { message in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack { Text(message.role == "user" ? "You" : "Hope").font(.subheadline.weight(.semibold)); Spacer(); if message.status != "completed" { Text(message.status.capitalized).font(.caption).foregroundStyle(.secondary) } }
|
||||
RichChatMarkdown(content: message.content)
|
||||
Menu {
|
||||
Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = message.content }
|
||||
Button("Branch from here", systemImage: "arrow.triangle.branch") { Task { await store.branch(message) } }
|
||||
if message.role == "user" { Button("Edit in new branch", systemImage: "pencil") { editing = message; editedText = message.content } }
|
||||
else { Button("Regenerate in new branch", systemImage: "arrow.clockwise") { Task { await store.branch(message, regenerate: true) } } }
|
||||
} label: { Label("Message actions", systemImage: "ellipsis").font(.caption).frame(minHeight: 44) }
|
||||
.disabled(store.busy || store.activeRun != nil)
|
||||
}.id(message.id)
|
||||
}
|
||||
if let progress = store.progress { Label(progress, systemImage: "ellipsis").foregroundStyle(.secondary) }
|
||||
Color.clear.frame(height: 1).id("chat-bottom")
|
||||
}.padding(.horizontal, 20).padding(.vertical, 16).frame(maxWidth: 760)
|
||||
}
|
||||
.simultaneousGesture(DragGesture().onChanged { _ in follow = false })
|
||||
.onChange(of: store.messages) { _, _ in if follow { proxy.scrollTo("chat-bottom", anchor: .bottom) } }
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !follow && !store.messages.isEmpty { Button("Latest", systemImage: "arrow.down") { follow = true; proxy.scrollTo("chat-bottom", anchor: .bottom) }.buttonStyle(.bordered).padding() }
|
||||
}
|
||||
}
|
||||
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
|
||||
.foregroundStyle(HopeTheme.color(.ink, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
|
||||
.navigationTitle(store.selected?.title ?? "Chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("Talk", systemImage: "waveform") { if let id = store.selected?.id { Task { await talk(id) } } }.disabled(store.activeRun != nil)
|
||||
Button("Export Markdown") { Task { await store.exportConversation(format: "markdown") } }
|
||||
Button("Export JSON") { Task { await store.exportConversation(format: "json") } }
|
||||
if let text = store.exportText { ShareLink("Share export", item: text) }
|
||||
} label: { Image(systemName: "ellipsis.circle").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Chat actions")
|
||||
} }
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let error = store.error { Text(error).font(.footnote).foregroundStyle(.red); if store.activeRun != nil { Button("Resume response") { store.resume() } } }
|
||||
if let approval = store.approval {
|
||||
Text(approval.prompt).font(.body)
|
||||
HStack { Button("Deny", role: .destructive) { Task { await store.decide("deny") } }; Button("Approve") { Task { await store.decide("approve") } } }
|
||||
}
|
||||
if store.researchAvailable { Toggle("Research with cited sources", isOn: $store.research).font(.footnote) }
|
||||
if !store.selectedAttachmentIds.isEmpty { Text("\(store.selectedAttachmentIds.count) attached files").font(.caption); Button("Remove attachments") { store.selectedAttachmentIds = [] } }
|
||||
HStack(alignment: .bottom) {
|
||||
Button { library = true } label: { Image(systemName: "paperclip").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Attach files")
|
||||
TextField("Message Hope", text: $store.draft, axis: .vertical).lineLimit(1...7).textFieldStyle(.roundedBorder).accessibilityIdentifier("chat-draft")
|
||||
if store.activeRun != nil { Button("Stop", systemImage: "stop.fill") { Task { await store.stop() } }.frame(minWidth: 44, minHeight: 44) }
|
||||
else { Button { follow = true; Task { await store.send() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title).frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Send message").disabled(store.busy || store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
|
||||
}
|
||||
}.padding(16).background(.regularMaterial)
|
||||
}
|
||||
.sheet(isPresented: $library) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
|
||||
.sheet(item: $editing) { message in
|
||||
NavigationStack {
|
||||
Form { Section("Edit message") { TextEditor(text: $editedText).frame(minHeight: 200) }; Text("A new branch preserves the original conversation.").font(.footnote).foregroundStyle(.secondary) }
|
||||
.navigationTitle("Edit in branch")
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { editing = nil } }; ToolbarItem(placement: .confirmationAction) { Button("Send") { editing = nil; Task { await store.branch(message, edited: editedText) } }.disabled(editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in if phase == .active { store.resume() } }
|
||||
}
|
||||
}
|
||||
|
||||
/// Native selectable prose and fenced code; no WebView or remote rendering.
|
||||
struct RichChatMarkdown: View {
|
||||
let content: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
|
||||
if block.code {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack { Text(block.language.isEmpty ? "Code" : block.language).font(.caption).foregroundStyle(.secondary); Spacer(); Button("Copy code") { UIPasteboard.general.string = block.text }.font(.caption).frame(minHeight: 44) }
|
||||
ScrollView(.horizontal) { Text(block.text).font(.system(.body, design: .monospaced)).textSelection(.enabled).fixedSize(horizontal: true, vertical: false) }
|
||||
}.padding(12).background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
} else {
|
||||
ForEach(Array(block.text.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
|
||||
if line.hasPrefix("# ") { markdown(String(line.dropFirst(2))).font(.title2.weight(.semibold)) }
|
||||
else if line.hasPrefix("## ") { markdown(String(line.dropFirst(3))).font(.title3.weight(.semibold)) }
|
||||
else if line.hasPrefix("### ") { markdown(String(line.dropFirst(4))).font(.headline) }
|
||||
else if line.hasPrefix("- ") || line.hasPrefix("* ") { HStack(alignment: .top) { Text("•"); markdown(String(line.dropFirst(2))) } }
|
||||
else if line.hasPrefix("> ") { markdown(String(line.dropFirst(2))).foregroundStyle(.secondary) }
|
||||
else { markdown(line) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}.textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
private func markdown(_ text: String) -> Text {
|
||||
Text((try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text))
|
||||
}
|
||||
private var blocks: [(text: String, code: Bool, language: String)] {
|
||||
let pieces = content.components(separatedBy: "```")
|
||||
return pieces.enumerated().map { i, piece in
|
||||
if i.isMultiple(of: 2) { return (piece, false, "") }
|
||||
let lines = piece.components(separatedBy: "\n")
|
||||
return (lines.dropFirst().joined(separator: "\n").trimmingCharacters(in: .newlines), true, lines.first ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatPreferencesView: View {
|
||||
@ObservedObject var store: ChatStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var palette = "opal"
|
||||
@State private var appearance = "system"
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Appearance") {
|
||||
Picker("Palette", selection: $palette) { ForEach(["opal", "graphite", "ocean", "sage", "lavender"], id: \.self) { Text($0.capitalized).tag($0) } }
|
||||
Picker("Appearance", selection: $appearance) { ForEach(["system", "light", "dark"], id: \.self) { Text($0.capitalized).tag($0) } }
|
||||
Button("Save appearance") { Task { await store.savePreferences(palette: palette, appearance: appearance) } }
|
||||
}
|
||||
Section("Server model") {
|
||||
if !store.models.isEmpty {
|
||||
Picker("Model", selection: $store.model) { ForEach(store.models) { Text($0.id).tag($0.id) } }
|
||||
if let levels = store.models.first(where: { $0.id == store.model })?.thinkingLevels, !levels.isEmpty { Picker("Thinking", selection: $store.thinkingLevel) { ForEach(levels, id: \.self) { Text($0.capitalized).tag($0) } } }
|
||||
Button("Save model settings") { Task { await store.saveModel() } }
|
||||
}
|
||||
if let note = store.modelNote { Text(note).font(.footnote).foregroundStyle(.secondary) }
|
||||
Text("Models are provided by your authenticated Hope server. No direct provider connection.").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = store.error { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
.navigationTitle("Chat preferences")
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
||||
.task { palette = store.preferences.palette; appearance = store.preferences.appearance; await store.loadModels() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceManagerView: View {
|
||||
@ObservedObject var store: ChatStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var editing: HopeWorkspace?
|
||||
@State private var name = ""
|
||||
@State private var instructions = ""
|
||||
@State private var deleting: HopeWorkspace?
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Workspaces") {
|
||||
ForEach(store.workspaces) { space in
|
||||
Button(space.name) { editing = space; name = space.name; instructions = space.instructions }
|
||||
.swipeActions { Button("Delete", role: .destructive) { deleting = space } }
|
||||
}
|
||||
}
|
||||
Section(editing == nil ? "New workspace" : "Edit workspace") {
|
||||
TextField("Workspace name", text: $name)
|
||||
TextField("Instructions", text: $instructions, axis: .vertical).lineLimit(3...8)
|
||||
Text("Instructions guide chats. Workspaces do not isolate tool access.").font(.footnote).foregroundStyle(.secondary)
|
||||
Button("Save workspace") { Task { await store.saveWorkspace(editing, name: name, instructions: instructions); editing = nil; name = ""; instructions = "" } }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
if editing != nil { Button("Cancel edit") { editing = nil; name = ""; instructions = "" } }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Workspaces")
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
||||
.confirmationDialog("Delete workspace?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) {
|
||||
Button("Delete workspace", role: .destructive) { if let deleting { Task { await store.deleteWorkspace(deleting) } }; deleting = nil }
|
||||
} message: { Text("Its chats are retained outside the workspace.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user