import SwiftUI /// "Ask Your Bookmarks" — the on-device RAG surface. Questions are answered by /// Apple's on-device model, grounded in the user's bookmarks via Spotlight. struct AskView: View { @State private var assistant = BookmarkAssistant() @State private var question = "" @State private var answer = "" @State private var isLoading = false @State private var errorText: String? @Environment(\.dismiss) private var dismiss @FocusState private var focused: Bool var body: some View { NavigationStack { Group { switch assistant.status { case .checking: ProgressView() case .unavailable(let message): ContentUnavailableView( "Unavailable", systemImage: "sparkles.slash", description: Text(message) ) case .ready: ready } } .navigationTitle("Ask Your Bookmarks") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } } } } private var ready: some View { VStack(spacing: 0) { ScrollView { VStack(alignment: .leading, spacing: 16) { if answer.isEmpty && !isLoading && errorText == nil { ContentUnavailableView { Label("Ask anything", systemImage: "sparkles") } description: { Text("Answers come from your saved bookmarks, generated on-device.") } .padding(.top, 40) } if isLoading { HStack(spacing: 8) { ProgressView() Text("Searching your bookmarks…").foregroundStyle(.secondary) } } if !answer.isEmpty { MarkdownAnswer(text: answer) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } if let errorText { Text(errorText).foregroundStyle(.red) } } .padding() } HStack(spacing: 10) { TextField("Ask about your bookmarks…", text: $question, axis: .vertical) .lineLimit(1...4) .focused($focused) .submitLabel(.send) .onSubmit(send) Button(action: send) { Image(systemName: "arrow.up.circle.fill").font(.title2) } .disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) } .padding() .background(.bar) } .onAppear { focused = true } } private func send() { let q = question.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty, !isLoading else { return } question = "" answer = "" errorText = nil isLoading = true Task { do { answer = try await assistant.ask(q) } catch { errorText = error.localizedDescription } isLoading = false } } } /// Renders the model's answer as lightweight Markdown. SwiftUI's `Text` only /// parses *inline* Markdown (bold/italic/code/links), so we split into block /// elements — headings, bullet/numbered lists, paragraphs — and lay them out, /// applying inline parsing per line. private struct MarkdownAnswer: View { let text: String private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) } var body: some View { VStack(alignment: .leading, spacing: 8) { ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in switch block { case .heading(let line): inline(line).font(.headline) case .bullet(let line): HStack(alignment: .firstTextBaseline, spacing: 8) { Text("•").foregroundStyle(.secondary) inline(line) } case .paragraph(let line): inline(line) } } } } private var blocks: [Block] { text.split(separator: "\n", omittingEmptySubsequences: true).map { raw in let line = raw.trimmingCharacters(in: .whitespaces) if let r = line.range(of: "^#{1,6}\\s+", options: .regularExpression) { return .heading(String(line[r.upperBound...])) } if let r = line.range(of: "^([-*+]|\\d+\\.)\\s+", options: .regularExpression) { return .bullet(String(line[r.upperBound...])) } return .paragraph(line) } } private func inline(_ s: String) -> Text { let opts = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) if let attr = try? AttributedString(markdown: s, options: opts) { return Text(attr) } return Text(s) } }