All checks were successful
CI / build-and-deploy (push) Successful in 22s
- SpotlightIndexer: actually push BookmarkEntity into the Spotlight index via indexAppEntities (IndexedEntity conformance alone indexes nothing). Wired into every sync/mutation point. This is what lets Apple Intelligence answer free-form Siri questions grounded in the user's bookmarks. - On-device RAG: BookmarkAssistant (LanguageModelSession) + BookmarkSearchTool + SpotlightBookmarkSearch (CSSearchQuery retrieval), surfaced via AskView with lightweight Markdown rendering of answers. - Log: os.Logger facility (com.magicive.marks) replacing ad-hoc print(); shared with ShareExtension. ClaudeService now logs server error bodies on non-2xx. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
5.5 KiB
Swift
153 lines
5.5 KiB
Swift
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)
|
|
}
|
|
}
|