feat(ai): on-device RAG over bookmarks + Spotlight indexing + unified logging
All checks were successful
CI / build-and-deploy (push) Successful in 22s
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>
This commit is contained in:
152
Marks/Views/AskView.swift
Normal file
152
Marks/Views/AskView.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ struct BookmarksView: View {
|
||||
@State private var browsingBookmark: Bookmark?
|
||||
@State private var showFullPlayer = false
|
||||
@State private var showPodcastLibrary = false
|
||||
@State private var showAsk = false
|
||||
@State private var readingProgress: [String: Double] = ReadingProgress.all()
|
||||
|
||||
var body: some View {
|
||||
@@ -95,6 +96,11 @@ struct BookmarksView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Menu {
|
||||
Button {
|
||||
showAsk = true
|
||||
} label: {
|
||||
Label("Ask Your Bookmarks", systemImage: "bubble.left.and.text.bubble.right")
|
||||
}
|
||||
Button {
|
||||
Task { await viewModel.generateSmartCollections() }
|
||||
showCollections = true
|
||||
@@ -167,6 +173,9 @@ struct BookmarksView: View {
|
||||
.sheet(isPresented: $showCollections) {
|
||||
CollectionsView(viewModel: viewModel)
|
||||
}
|
||||
.sheet(isPresented: $showAsk) {
|
||||
AskView()
|
||||
}
|
||||
.sheet(isPresented: $showAddBookmark) {
|
||||
AddBookmarkView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ final class BookmarksViewModel {
|
||||
WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map {
|
||||
WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl)
|
||||
})
|
||||
Task { await SpotlightIndexer.index(bookmarks) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -57,6 +58,8 @@ final class BookmarksViewModel {
|
||||
bookmarks.append(contentsOf: response.results)
|
||||
nextPageUrl = response.next
|
||||
restoreAIData()
|
||||
let added = response.results
|
||||
Task { await SpotlightIndexer.index(added) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -102,21 +105,24 @@ final class BookmarksViewModel {
|
||||
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
|
||||
let bookmark = try await api.createBookmark(create)
|
||||
bookmarks.insert(bookmark, at: 0)
|
||||
print("[AI] addBookmark: saved id=\(bookmark.id)")
|
||||
Task { await SpotlightIndexer.index([bookmark]) }
|
||||
Log.ai.info("addBookmark saved id=\(bookmark.id, privacy: .public)")
|
||||
Task {
|
||||
print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)")
|
||||
Log.ai.info("addBookmark enrich start id=\(bookmark.id, privacy: .public)")
|
||||
do {
|
||||
let (summary, aiTags) = try await claude.enrich(bookmark: bookmark)
|
||||
print("[AI] addBookmark: enrich success summary=\(summary.prefix(60)) tags=\(aiTags)")
|
||||
Log.ai.info("addBookmark enrich ok id=\(bookmark.id, privacy: .public) tags=\(aiTags.count, privacy: .public)")
|
||||
guard let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else {
|
||||
print("[AI] addBookmark: bookmark id=\(bookmark.id) not found in list after enrich")
|
||||
Log.ai.notice("addBookmark id=\(bookmark.id, privacy: .public) gone before enrich applied")
|
||||
return
|
||||
}
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = aiTags
|
||||
saveAIData(for: bookmarks[i])
|
||||
let enriched = bookmarks[i]
|
||||
Task { await SpotlightIndexer.index([enriched]) }
|
||||
} catch {
|
||||
print("[AI] addBookmark: enrich FAILED \(error)")
|
||||
Log.ai.error("addBookmark enrich failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.error = "AI enrichment failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
@@ -126,6 +132,7 @@ final class BookmarksViewModel {
|
||||
do {
|
||||
try await api.deleteBookmark(id: bookmark.id)
|
||||
bookmarks.removeAll { $0.id == bookmark.id }
|
||||
Task { await SpotlightIndexer.remove(ids: [bookmark.id]) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -135,6 +142,7 @@ final class BookmarksViewModel {
|
||||
do {
|
||||
try await api.archiveBookmark(id: bookmark.id)
|
||||
bookmarks.removeAll { $0.id == bookmark.id }
|
||||
Task { await SpotlightIndexer.remove(ids: [bookmark.id]) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -180,6 +188,8 @@ final class BookmarksViewModel {
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = tags
|
||||
saveAIData(for: bookmarks[i])
|
||||
let enriched = bookmarks[i]
|
||||
Task { await SpotlightIndexer.index([enriched]) }
|
||||
enrichmentProgress = Double(done + 1) / Double(toEnrich.count)
|
||||
} catch {
|
||||
break
|
||||
@@ -196,23 +206,25 @@ final class BookmarksViewModel {
|
||||
enrichTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) }
|
||||
print("[AI] startEnrichment: \(indices.count) bookmarks to enrich")
|
||||
Log.ai.info("startEnrichment: \(indices.count, privacy: .public) to enrich")
|
||||
for i in indices {
|
||||
guard !Task.isCancelled else { return }
|
||||
let claude = await MainActor.run(body: { self.claude })
|
||||
do {
|
||||
let bm = await MainActor.run { bookmarks[i] }
|
||||
print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)")
|
||||
Log.ai.debug("startEnrichment enriching id=\(bm.id, privacy: .public)")
|
||||
let (summary, tags) = try await claude.enrich(bookmark: bm)
|
||||
print("[AI] startEnrichment: done id=\(bm.id) summary=\(summary.prefix(60))")
|
||||
Log.ai.debug("startEnrichment done id=\(bm.id, privacy: .public)")
|
||||
await MainActor.run {
|
||||
guard i < bookmarks.count else { return }
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = tags
|
||||
saveAIData(for: bookmarks[i])
|
||||
let enriched = bookmarks[i]
|
||||
Task { await SpotlightIndexer.index([enriched]) }
|
||||
}
|
||||
} catch {
|
||||
print("[AI] startEnrichment: enrich FAILED id=\(i) \(error)")
|
||||
Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -239,7 +251,7 @@ final class BookmarksViewModel {
|
||||
let data = try Self.cacheEncoder.encode(list)
|
||||
try data.write(to: cacheFileUrl, options: .atomic)
|
||||
} catch {
|
||||
print("[Cache] saveToCache failed: \(error)")
|
||||
Log.sync.error("saveToCache failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user