feat: refine native iOS chat experience
This commit is contained in:
1 parent
9c5e9bce5b
commit
7bd893ae5c
14 files changed
+415
-93
No files matched your search
@@ -36,8 +36,9 @@ final class SettingsStore: ObservableObject {
|
||||
d.set(Self.defaultServerURL, forKey: Keys.serverURL)
|
||||
}
|
||||
let storedVoice = d.string(forKey: Keys.voiceID)
|
||||
voiceID = Voices.all.contains { $0.id == storedVoice } ? (storedVoice ?? "") : Voices.defaultID
|
||||
voiceID = Voices.restoredID(storedVoice)
|
||||
textMode = d.bool(forKey: Keys.textMode)
|
||||
d.set(voiceID, forKey: Keys.voiceID)
|
||||
}
|
||||
|
||||
var trimmedServerURL: String {
|
||||
|
||||
+42
-12
@@ -1,33 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
struct VoiceOption: Equatable {
|
||||
struct VoiceOption: Codable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let tag: String?
|
||||
var locale: String? = nil
|
||||
var gender: String? = nil
|
||||
|
||||
var details: String {
|
||||
[locale, gender, tag].compactMap { $0 }.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
tag.map { "\(name) (\($0))" } ?? name
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceCatalogResponse: Decodable {
|
||||
let defaultVoice: String
|
||||
let voices: [VoiceOption]
|
||||
let catalogAvailable: Bool
|
||||
let message: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case voices, message
|
||||
case defaultVoice = "default_voice"
|
||||
case catalogAvailable = "catalog_available"
|
||||
}
|
||||
}
|
||||
|
||||
enum Voices {
|
||||
static let all: [VoiceOption] = [
|
||||
VoiceOption(id: "en-US-Ava:DragonHDOmniLatestNeural", name: "Ava", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-JennyNeural", name: "Jenny", tag: nil),
|
||||
VoiceOption(id: "en-US-GuyNeural", name: "Guy", tag: nil),
|
||||
VoiceOption(id: "en-US-AndrewNeural", name: "Andrew", tag: nil),
|
||||
VoiceOption(id: "en-US-AriaNeural", name: "Aria", tag: nil),
|
||||
VoiceOption(id: "en-US-EmmaNeural", name: "Emma", tag: nil),
|
||||
VoiceOption(id: "en-US-EricNeural", name: "Eric", tag: nil),
|
||||
VoiceOption(id: "en-US-BrianNeural", name: "Brian", tag: nil),
|
||||
VoiceOption(id: "en-US-AshleyNeural", name: "Ashley", tag: nil),
|
||||
VoiceOption(id: "en-US-RichardNeural", name: "Richard", tag: nil),
|
||||
VoiceOption(id: "en-US-TinaNeural", name: "Tina", tag: nil),
|
||||
VoiceOption(id: "en-US-SteffanNeural", name: "Steffan", tag: nil),
|
||||
VoiceOption(id: "en-US-Jenny:DragonHDOmniLatestNeural", name: "Jenny", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Guy:DragonHDOmniLatestNeural", name: "Guy", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Andrew:DragonHDOmniLatestNeural", name: "Andrew", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Aria:DragonHDOmniLatestNeural", name: "Aria", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Emma:DragonHDOmniLatestNeural", name: "Emma", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Eric:DragonHDOmniLatestNeural", name: "Eric", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Brian:DragonHDOmniLatestNeural", name: "Brian", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Ashley:DragonHDOmniLatestNeural", name: "Ashley", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Richard:DragonHDOmniLatestNeural", name: "Richard", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Tina:DragonHDOmniLatestNeural", name: "Tina", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Steffan:DragonHDOmniLatestNeural", name: "Steffan", tag: "Dragon HD Omni"),
|
||||
]
|
||||
|
||||
static var defaultID: String { all[0].id }
|
||||
|
||||
private static let legacyPersonaIDs: [String: String] = Dictionary(uniqueKeysWithValues: all.compactMap { option in
|
||||
guard let prefix = option.id.split(separator: ":").first else { return nil }
|
||||
return ("\(prefix)Neural", option.id)
|
||||
})
|
||||
|
||||
static func restoredID(_ saved: String?) -> String {
|
||||
guard let saved, saved.range(of: "^[A-Za-z0-9:_-]{1,120}$", options: .regularExpression) != nil else { return defaultID }
|
||||
if let migrated = legacyPersonaIDs[saved] { return migrated }
|
||||
return saved.replacingOccurrences(of: ":DragonHDLatestNeural", with: ":DragonHDOmniLatestNeural")
|
||||
}
|
||||
|
||||
static func display(for id: String) -> String? {
|
||||
all.first { $0.id == id }?.displayName
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ final class ChatFeatureStore: ObservableObject {
|
||||
@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
|
||||
@@ -65,15 +68,36 @@ final class ChatFeatureStore: ObservableObject {
|
||||
} 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 else { return }
|
||||
previewPDF = nil; previewText = nil; previewImage = nil
|
||||
if attachment.mime.hasPrefix("image/") { previewImage = UIImage(data: data); previewText = nil }
|
||||
else if attachment.mime == "application/pdf" { previewPDF = data; previewText = nil; previewImage = nil }
|
||||
else { previewText = String(data: data, encoding: .utf8); previewImage = nil }
|
||||
} catch { report(error) }
|
||||
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 } }
|
||||
@@ -111,7 +135,7 @@ final class ChatFeatureStore: ObservableObject {
|
||||
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 = []; previewText = nil; previewImage = nil; previewPDF = nil; error = nil }
|
||||
func close() { closed = true; epoch += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil }
|
||||
}
|
||||
|
||||
struct ChatLibraryView: View {
|
||||
@@ -121,6 +145,7 @@ struct ChatLibraryView: View {
|
||||
@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 = ""
|
||||
@@ -138,7 +163,11 @@ struct ChatLibraryView: View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } }
|
||||
if store.enabled.isEmpty { ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown.")) }
|
||||
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)
|
||||
@@ -146,8 +175,13 @@ struct ChatLibraryView: View {
|
||||
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) } } 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") { Task { await store.preview(item); showingPreview = true } }.frame(minHeight: 44)
|
||||
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) } } }
|
||||
@@ -183,7 +217,7 @@ struct ChatLibraryView: View {
|
||||
}
|
||||
.navigationTitle("Library and tools")
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
||||
.task { await store.load() }
|
||||
.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() } }
|
||||
@@ -197,10 +231,37 @@ struct ChatLibraryView: View {
|
||||
} }
|
||||
}
|
||||
.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) { NavigationStack { ScrollView { if let pdf = store.previewPDF { PrivatePDFPreview(data: pdf).frame(minHeight: 500) }; if let image = store.previewImage { Image(uiImage: image).resizable().scaledToFit() }; if let text = store.previewText { Text(text).textSelection(.enabled).padding() } }.navigationTitle("Private preview").toolbar { Button("Done") { showingPreview = false; store.previewImage = nil; store.previewText = nil; store.previewPDF = 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 { TextEditor(text: $memoryContent).frame(minHeight: 260); Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote) }.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 } } } } } } }
|
||||
.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 } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,14 +208,24 @@ final class ChatStore: ObservableObject {
|
||||
else if regenerate { draft = target.content; await send() }
|
||||
} catch { busy = false; await failure(error) }
|
||||
}
|
||||
func saveWorkspace(_ workspace: HopeWorkspace?, name: String, instructions: String) async {
|
||||
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
@discardableResult
|
||||
func saveWorkspace(_ workspace: HopeWorkspace?, name: String, instructions: String) async -> Bool {
|
||||
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
error = "Enter a workspace name."
|
||||
return false
|
||||
}
|
||||
do {
|
||||
var body: [String: Any] = ["name": name, "instructions": instructions]
|
||||
if let workspace { body["revision"] = workspace.revision }
|
||||
let _: WorkspaceEnvelope = try await call(workspace.map { "api/workspaces/\($0.id)" } ?? "api/workspaces", method: workspace == nil ? "POST" : "PATCH", body: body)
|
||||
let result: WorkspacePage = try await call("api/workspaces"); workspaces = result.workspaces
|
||||
} catch { await failure(error) }
|
||||
let result: WorkspacePage = try await call("api/workspaces")
|
||||
workspaces = result.workspaces
|
||||
error = nil
|
||||
return true
|
||||
} catch {
|
||||
await failure(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
func deleteWorkspace(_ workspace: HopeWorkspace) async {
|
||||
do {
|
||||
|
||||
@@ -21,11 +21,12 @@ struct NativeChatView: View {
|
||||
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 visible.isEmpty && !store.searching {
|
||||
ContentUnavailableView(emptyTitle, systemImage: "bubble.left.and.bubble.right", description: Text(emptyDescription))
|
||||
}
|
||||
if workspace != nil || archived { Section { Label(scopeLabel, systemImage: archived ? "archivebox" : "folder") } }
|
||||
if !pinned.isEmpty { Section("Pinned") { rows(pinned) } }
|
||||
Section(archived ? "Archived" : "Recent chats") { rows(recent) }
|
||||
if !recent.isEmpty { 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)
|
||||
@@ -65,7 +66,8 @@ struct NativeChatView: View {
|
||||
.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 }
|
||||
Button("Save") { if let rename { Task { await store.patch(rename, changes: ["title": title]); if store.error == nil { self.rename = nil } } } }
|
||||
.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.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 }
|
||||
@@ -74,6 +76,18 @@ struct NativeChatView: View {
|
||||
}
|
||||
}
|
||||
private var visible: [HopeConversation] { store.conversations.filter { workspace == nil || $0.workspaceId == workspace } }
|
||||
private var scopeLabel: String { archived ? "Archived chats" : (workspace.flatMap { id in store.workspaces.first(where: { $0.id == id })?.name } ?? "All chats") }
|
||||
private var emptyTitle: String {
|
||||
if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matching chats" }
|
||||
if archived { return "No archived chats" }
|
||||
if workspace != nil { return "No chats in this workspace" }
|
||||
return "A place to think"
|
||||
}
|
||||
private var emptyDescription: String {
|
||||
if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "Try another search or clear it to see your conversations." }
|
||||
if workspace != nil { return "Move a chat here or start a new conversation in this workspace." }
|
||||
return archived ? "Archived chats appear here." : "Start a chat. Your conversations stay with your account, independently of Talk."
|
||||
}
|
||||
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) }
|
||||
@@ -83,9 +97,11 @@ struct NativeChatView: View {
|
||||
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)
|
||||
Text(conversation.title).foregroundStyle(.primary).font(.body.weight(.medium)).lineLimit(2)
|
||||
HStack(spacing: 6) {
|
||||
if let id = conversation.workspaceId, let space = store.workspaces.first(where: { $0.id == id }) { Text(space.name) }
|
||||
Text(Date(timeIntervalSince1970: conversation.updatedAt), style: .date)
|
||||
}.font(.caption).foregroundStyle(.secondary)
|
||||
}.frame(minHeight: 44, alignment: .leading)
|
||||
}
|
||||
.contextMenu {
|
||||
@@ -127,7 +143,11 @@ struct ChatDetailView: View {
|
||||
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)
|
||||
}
|
||||
.padding(message.role == "user" ? 12 : 0)
|
||||
.background(message.role == "user" ? HopeTheme.color(.surface, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal) : .clear, in: RoundedRectangle(cornerRadius: 12))
|
||||
.frame(maxWidth: .infinity, alignment: message.role == "user" ? .trailing : .leading)
|
||||
.id(message.id)
|
||||
}
|
||||
if let progress = store.progress { Label(progress, systemImage: "ellipsis").foregroundStyle(.secondary) }
|
||||
Color.clear.frame(height: 1).id("chat-bottom")
|
||||
@@ -160,7 +180,9 @@ struct ChatDetailView: View {
|
||||
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 = [] } }
|
||||
if !store.selectedAttachmentIds.isEmpty {
|
||||
AttachmentSelectionSummary(store: store)
|
||||
}
|
||||
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")
|
||||
@@ -181,6 +203,23 @@ struct ChatDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachmentSelectionSummary: View {
|
||||
@ObservedObject var store: ChatStore
|
||||
var selectedAttachments: [ChatAttachment] { store.features.attachments.filter { store.selectedAttachmentIds.contains($0.id) } }
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Attached to next message (\(store.selectedAttachmentIds.count)/8)").font(.caption.weight(.semibold))
|
||||
ForEach(selectedAttachments) { item in
|
||||
HStack {
|
||||
Label(item.filename, systemImage: "doc").font(.caption).lineLimit(1)
|
||||
Spacer()
|
||||
Button("Remove") { store.selectedAttachmentIds.remove(item.id) }.font(.caption).frame(minHeight: 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native selectable prose and fenced code; no WebView or remote rendering.
|
||||
struct RichChatMarkdown: View {
|
||||
let content: String
|
||||
@@ -277,7 +316,11 @@ struct WorkspaceManagerView: View {
|
||||
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)
|
||||
Button("Save workspace") { Task {
|
||||
let saved = await store.saveWorkspace(editing, name: name, instructions: instructions)
|
||||
if saved { editing = nil; name = ""; instructions = "" }
|
||||
} }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
if let error = store.error { Text(error).foregroundStyle(.red) }
|
||||
if editing != nil { Button("Cancel edit") { editing = nil; name = ""; instructions = "" } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ struct LoginView: View {
|
||||
@State private var password = ""
|
||||
@State private var server = ""
|
||||
@State private var serverError: String?
|
||||
@State private var savedServer: String?
|
||||
@State private var passwordVisible = false
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -29,11 +31,21 @@ struct LoginView: View {
|
||||
Section("Account") {
|
||||
TextField("Username", text: $username).textContentType(.username)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
SecureField("Password", text: $password).textContentType(.password)
|
||||
HStack {
|
||||
Group {
|
||||
if passwordVisible { TextField("Password", text: $password) }
|
||||
else { SecureField("Password", text: $password) }
|
||||
}
|
||||
.textContentType(.password)
|
||||
Button(passwordVisible ? "Hide" : "Show") { passwordVisible.toggle() }
|
||||
.accessibilityLabel(passwordVisible ? "Hide password" : "Show password")
|
||||
}
|
||||
Button("Sign in") {
|
||||
let suppliedPassword = password
|
||||
password = ""
|
||||
Task { await auth.login(username: username, password: suppliedPassword) }
|
||||
Task {
|
||||
await auth.login(username: username, password: suppliedPassword)
|
||||
if auth.user != nil { password = "" }
|
||||
}
|
||||
}
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -54,13 +66,19 @@ struct LoginView: View {
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
Button("Save server address") {
|
||||
switch ServerConfig.parse(server) {
|
||||
case .success(let config): settings.serverURL = config.baseURL.absoluteString
|
||||
case .failure(let error): serverError = error.localizedDescription
|
||||
case .success(let config):
|
||||
server = config.baseURL.absoluteString
|
||||
settings.serverURL = config.baseURL.absoluteString
|
||||
serverError = nil
|
||||
savedServer = config.baseURL.absoluteString
|
||||
case .failure(let error): serverError = error.localizedDescription; savedServer = nil
|
||||
}
|
||||
}.disabled(auth.isBusy)
|
||||
}.disabled(auth.isBusy || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || server == settings.serverURL)
|
||||
if let serverError {
|
||||
Text(serverError).foregroundStyle(Nearlight.alert)
|
||||
Label(serverError, systemImage: "exclamationmark.circle").foregroundStyle(Nearlight.alert)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if savedServer != nil {
|
||||
Label("Server address saved. Sign in uses this server.", systemImage: "checkmark.circle").font(.footnote).foregroundStyle(Nearlight.quiet)
|
||||
}
|
||||
Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.")
|
||||
.font(.footnote).foregroundStyle(Nearlight.quiet)
|
||||
@@ -81,6 +99,7 @@ struct LoginView: View {
|
||||
.navigationTitle("Hope")
|
||||
.toolbarBackground(Nearlight.ground, for: .navigationBar)
|
||||
.onAppear { server = settings.serverURL }
|
||||
.onChange(of: server) { _, _ in serverError = nil; savedServer = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ struct ConnectionTestView: View {
|
||||
if let result {
|
||||
switch result {
|
||||
case .success(let response):
|
||||
Section("Success") {
|
||||
Section("Server access verified") {
|
||||
LabeledContent("Room") {
|
||||
Text(response.room)
|
||||
.font(.footnote.monospaced())
|
||||
@@ -45,8 +45,9 @@ struct ConnectionTestView: View {
|
||||
LabeledContent("User") {
|
||||
Text(response.user.displayName ?? response.user.id)
|
||||
}
|
||||
Label("Connected to your server. Start a conversation to check Hope’s voice and replies.", systemImage: "checkmark.circle")
|
||||
Label("Account access and session setup verified. This does not test microphone permission, secure voice transport, or spoken replies.", systemImage: "checkmark.circle")
|
||||
.foregroundStyle(.green)
|
||||
Text("Start a conversation to verify live voice and replies.").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
case .failure(let error):
|
||||
Section("Error") {
|
||||
|
||||
@@ -13,6 +13,7 @@ struct SettingsView: View {
|
||||
@State private var urlError: String?
|
||||
@State private var showVoicePicker = false
|
||||
@State private var savedURLConfirmation: String?
|
||||
@State private var pendingServerChange: String?
|
||||
@FocusState private var urlFocused: Bool
|
||||
init(settings: SettingsStore, session: VoiceSession) {
|
||||
self.settings = settings
|
||||
@@ -82,7 +83,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.accessibilityIdentifier("settings.server.saved")
|
||||
}
|
||||
Button("Save server address") { applyServerURL() }
|
||||
Button("Change server") { requestServerURLChange() }
|
||||
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
|
||||
if !canApplyServerURL {
|
||||
Text(session.state == .tearingDown
|
||||
@@ -108,6 +109,15 @@ struct SettingsView: View {
|
||||
.sheet(isPresented: $showVoicePicker) {
|
||||
VoicePickerView(settings: settings, session: session)
|
||||
}
|
||||
.confirmationDialog("Change Hope server?", isPresented: Binding(get: { pendingServerChange != nil }, set: { if !$0 { pendingServerChange = nil } }), titleVisibility: .visible) {
|
||||
Button("Change server and sign out", role: .destructive) {
|
||||
if let pendingServerChange { applyServerURL(pendingServerChange) }
|
||||
pendingServerChange = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingServerChange = nil }
|
||||
} message: {
|
||||
Text("Changing servers signs you out and ends the current session. Conversations and account data remain with the current server.")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -115,47 +125,106 @@ struct SettingsView: View {
|
||||
/// draft stays in the field with an inline error; `settings.serverURL` is
|
||||
/// left untouched so the active session keeps using its original URL.
|
||||
/// Guarded: only applies when the session is idle (no active call).
|
||||
private func applyServerURL() {
|
||||
private func requestServerURLChange() {
|
||||
savedURLConfirmation = nil
|
||||
guard canApplyServerURL else {
|
||||
urlError = "End the conversation before changing servers."
|
||||
return
|
||||
}
|
||||
guard canApplyServerURL else { urlError = "End the conversation before changing servers."; return }
|
||||
let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
urlError = "Enter a server URL."
|
||||
return
|
||||
}
|
||||
guard !trimmed.isEmpty else { urlError = "Enter a server URL."; return }
|
||||
switch ServerConfig.parse(trimmed) {
|
||||
case .success:
|
||||
settings.serverURL = trimmed
|
||||
urlText = trimmed
|
||||
case .success(let config):
|
||||
pendingServerChange = config.baseURL.absoluteString
|
||||
urlError = nil
|
||||
savedURLConfirmation = trimmed
|
||||
urlFocused = false
|
||||
case .failure(let error):
|
||||
urlError = error.localizedDescription
|
||||
urlFocused = true
|
||||
}
|
||||
}
|
||||
private func applyServerURL(_ canonicalURL: String) {
|
||||
settings.serverURL = canonicalURL
|
||||
urlText = canonicalURL
|
||||
savedURLConfirmation = canonicalURL
|
||||
urlError = nil
|
||||
urlFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
struct VoicePickerView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
@ObservedObject var session: VoiceSession
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var voices = Voices.all
|
||||
@State private var search = ""
|
||||
@State private var loading = false
|
||||
@State private var catalogMessage: String?
|
||||
@State private var catalogAvailable = false
|
||||
@State private var requestID = UUID()
|
||||
@State private var applyingVoiceID: String?
|
||||
@State private var voiceError: String?
|
||||
|
||||
private var filteredVoices: [VoiceOption] {
|
||||
let query = search.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return voices.filter { query.isEmpty || "\($0.name) \($0.details)".localizedCaseInsensitiveContains(query) }
|
||||
}
|
||||
|
||||
@MainActor private func loadVoices() async {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
let id = UUID()
|
||||
requestID = id
|
||||
defer { if requestID == id { loading = false } }
|
||||
do {
|
||||
guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { throw AuthError.invalidResponse }
|
||||
let response = try await AuthClient(config: config).voices(token: session.bootstrapBearerToken)
|
||||
try Task.checkCancellation()
|
||||
guard requestID == id else { return }
|
||||
let valid = response.voices.filter { $0.id.range(of: "^[A-Za-z0-9:_-]{1,120}$", options: .regularExpression) != nil }
|
||||
guard !valid.isEmpty else { throw AuthError.invalidResponse }
|
||||
voices = valid
|
||||
catalogAvailable = response.catalogAvailable
|
||||
catalogMessage = response.catalogAvailable ? nil : response.message
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
guard !Task.isCancelled, requestID == id else { return }
|
||||
catalogAvailable = false
|
||||
catalogMessage = "Couldn’t load all English voices. Your current choices remain available. Check your sign-in and server update, then retry."
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(Voices.all, id: \.id) { voice in
|
||||
Section {
|
||||
Text("Ava Dragon HD Omni is the recommended female default. Search by name, locale, gender, or model.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if loading { ProgressView("Loading English voices…") }
|
||||
if let catalogMessage { Text(catalogMessage).font(.footnote).foregroundStyle(.secondary) }
|
||||
if let voiceError { Label(voiceError, systemImage: "exclamationmark.circle").font(.footnote).foregroundStyle(.red) }
|
||||
if !catalogAvailable && !loading {
|
||||
Button("Retry voice catalog") { Task { await loadVoices() } }
|
||||
}
|
||||
}
|
||||
if filteredVoices.isEmpty {
|
||||
Text("No matching voices. Try another name or locale.").foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(filteredVoices, id: \.id) { voice in
|
||||
Button {
|
||||
settings.voiceID = voice.id
|
||||
Task { await session.selectVoice(voice.id) }
|
||||
dismiss()
|
||||
guard applyingVoiceID == nil else { return }
|
||||
applyingVoiceID = voice.id
|
||||
voiceError = nil
|
||||
Task {
|
||||
let applied = await session.selectVoice(voice.id)
|
||||
applyingVoiceID = nil
|
||||
if applied { dismiss() }
|
||||
else { voiceError = "Couldn’t apply this voice to the live conversation. Your previous voice is still selected." }
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(voice.displayName)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(voice.name)
|
||||
Text(voice.details).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if settings.voiceID == voice.id {
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
@@ -163,10 +232,14 @@ struct VoicePickerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(applyingVoiceID != nil)
|
||||
.accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.navigationTitle("Voice")
|
||||
.navigationTitle("English voices")
|
||||
.searchable(text: $search, prompt: "Name, locale, gender, or Omni")
|
||||
.task { await loadVoices() }
|
||||
.onDisappear { requestID = UUID(); loading = false }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
|
||||
@@ -377,16 +377,21 @@ final class VoiceSession: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func selectVoice(_ id: String) async {
|
||||
settings.voiceID = id
|
||||
guard isReady else { return }
|
||||
@discardableResult
|
||||
func selectVoice(_ id: String) async -> Bool {
|
||||
let previous = settings.voiceID
|
||||
guard isReady else { settings.voiceID = id; return true }
|
||||
let generation = sessionGeneration
|
||||
do {
|
||||
try await controlChannel.setVoice(id, room: room)
|
||||
guard generation == sessionGeneration else { return }
|
||||
guard generation == sessionGeneration else { return false }
|
||||
settings.voiceID = id
|
||||
logger.log("set_voice sent: \(id, privacy: .public)")
|
||||
return true
|
||||
} catch {
|
||||
settings.voiceID = previous
|
||||
logger.error("set_voice failed: \(String(describing: error), privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,18 @@ final class AuthClient {
|
||||
struct Response: Decodable { let user: AuthUser }
|
||||
return try JSONDecoder().decode(Response.self, from: await request("api/auth/me", method: "GET", token: token)).user
|
||||
}
|
||||
func voices(token: String) async throws -> VoiceCatalogResponse {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-testing"),
|
||||
!ProcessInfo.processInfo.arguments.contains("--live-testing") {
|
||||
return VoiceCatalogResponse(defaultVoice: Voices.defaultID, voices: Voices.all,
|
||||
catalogAvailable: false, message: "Offline test voice catalog.")
|
||||
}
|
||||
#endif
|
||||
guard !token.isEmpty else { throw AuthError.status(401) }
|
||||
return try JSONDecoder().decode(VoiceCatalogResponse.self, from: await request("voices", method: "GET", token: token))
|
||||
}
|
||||
|
||||
func logout(token: String) async throws {
|
||||
_ = try await request("api/auth/logout", method: "POST", token: token)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user