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): PaperEmptyState( title: "Unavailable", systemImage: "sparkles.slash", message: message ) case .ready: ready } } .paperSurface() .navigationTitle("Ask Your Bookmarks") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } .font(PaperType.label) .tint(Paper.accent) } } } } private var ready: some View { VStack(spacing: 0) { ScrollView { VStack(alignment: .leading, spacing: 16) { if answer.isEmpty && !isLoading && errorText == nil { PaperEmptyState( title: "Ask anything", systemImage: "sparkles", message: "Answers come from your saved bookmarks, generated on-device." ) .padding(.top, 40) } if isLoading { HStack(spacing: 8) { ProgressView() Text("Searching your bookmarks…") .font(PaperType.meta) .foregroundStyle(Paper.secondary) } } if !answer.isEmpty { MarkdownAnswer(text: answer) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } if let errorText { Text(errorText) .font(PaperType.meta) .foregroundStyle(Paper.alarm) } } .padding() } HStack(spacing: 10) { TextField("Ask about your bookmarks…", text: $question, axis: .vertical) .font(PaperType.body) .foregroundStyle(Paper.ink) .lineLimit(1...4) .focused($focused) .submitLabel(.send) .onSubmit(send) Button(action: send) { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 26)) .foregroundStyle(Paper.accent) } .disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) } .padding() .background(Paper.raised) .overlay(alignment: .top) { Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6) } } .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: 10) { ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in switch block { case .heading(let line): inline(line).font(PaperType.heading) case .bullet(let line): HStack(alignment: .firstTextBaseline, spacing: 8) { Text("•").foregroundStyle(Paper.tertiary) inline(line).font(PaperType.body) } case .paragraph(let line): inline(line).font(PaperType.body) } } } } 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) } }