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:
@@ -12,17 +12,22 @@
|
||||
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
|
||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
|
||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
|
||||
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
||||
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; };
|
||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
|
||||
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
|
||||
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E6702DE1210AC38ED16D1 /* AskView.swift */; };
|
||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
|
||||
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
|
||||
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
|
||||
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
|
||||
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; };
|
||||
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
|
||||
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */; };
|
||||
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7A85A23A13D754F6A75E4D /* ShareView.swift */; };
|
||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
|
||||
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; };
|
||||
@@ -35,8 +40,10 @@
|
||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
|
||||
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
|
||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
|
||||
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; };
|
||||
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; };
|
||||
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
|
||||
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
|
||||
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; };
|
||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; };
|
||||
@@ -103,8 +110,11 @@
|
||||
093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
|
||||
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
|
||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
|
||||
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
|
||||
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; };
|
||||
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; };
|
||||
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
|
||||
49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentBookmarksWidget.swift; sourceTree = "<group>"; };
|
||||
@@ -133,6 +143,7 @@
|
||||
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = "<group>"; };
|
||||
B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = "<group>"; };
|
||||
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = "<group>"; };
|
||||
C8B10AC011AF8F242E525440 /* MarksTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MarksTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
|
||||
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
|
||||
@@ -142,10 +153,12 @@
|
||||
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; };
|
||||
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
|
||||
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
|
||||
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = "<group>"; };
|
||||
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
|
||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
|
||||
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; };
|
||||
FB7728D15C17219ABFF3EFFE /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
|
||||
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -232,10 +245,15 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
|
||||
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
|
||||
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
|
||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
||||
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
|
||||
FB7728D15C17219ABFF3EFFE /* Log.swift */,
|
||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
||||
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
|
||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -258,6 +276,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
|
||||
217E6702DE1210AC38ED16D1 /* AskView.swift */,
|
||||
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
|
||||
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
|
||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
||||
@@ -460,6 +479,7 @@
|
||||
files = (
|
||||
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
|
||||
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
|
||||
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
|
||||
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
|
||||
F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */,
|
||||
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */,
|
||||
@@ -486,11 +506,14 @@
|
||||
files = (
|
||||
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
|
||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
|
||||
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
|
||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
|
||||
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */,
|
||||
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
|
||||
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
|
||||
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */,
|
||||
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
|
||||
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */,
|
||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
|
||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
||||
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
|
||||
@@ -500,6 +523,7 @@
|
||||
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
|
||||
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
|
||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||
212F713DCC289C48087B79AE /* Log.swift in Sources */,
|
||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
|
||||
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
|
||||
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
|
||||
@@ -509,6 +533,8 @@
|
||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
|
||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */,
|
||||
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */,
|
||||
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
|
||||
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
|
||||
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */,
|
||||
|
||||
@@ -133,6 +133,13 @@ struct SummarizeBookmarkIntent: AppIntent {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: there is intentionally no "Ask" AppIntent. Apple Intelligence already
|
||||
// answers free-form questions over the user's bookmarks by querying the
|
||||
// Spotlight index of `BookmarkEntity` (an `IndexedEntity`) directly — verified
|
||||
// on device (Campo → CoreSpotlight → on-device model). A named intent would be
|
||||
// redundant, and "Ask <app>" collides with Siri's built-in "Ask". The in-app
|
||||
// `AskView` remains as a manual on-device-RAG surface.
|
||||
|
||||
// MARK: - App Shortcuts
|
||||
|
||||
struct MarksShortcuts: AppShortcutsProvider {
|
||||
|
||||
@@ -18,10 +18,10 @@ enum Analytics {
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status < 200 || status > 299 {
|
||||
print("[Analytics] \(event) → HTTP \(status)")
|
||||
Log.analytics.error("\(event, privacy: .public) → HTTP \(status, privacy: .public)")
|
||||
}
|
||||
} catch {
|
||||
print("[Analytics] \(event) failed: \(error.localizedDescription)")
|
||||
Log.analytics.error("\(event, privacy: .public) failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
81
Marks/Services/BookmarkAssistant.swift
Normal file
81
Marks/Services/BookmarkAssistant.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import FoundationModels
|
||||
|
||||
/// Generation half of on-device RAG. Wraps a `LanguageModelSession` configured
|
||||
/// with `BookmarkSearchTool`, so every question is answered by Apple's
|
||||
/// on-device model grounded in the user's own bookmarks — no network, no
|
||||
/// third-party LLM. Mirrors the `ClaudeService` role, but fully on-device.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BookmarkAssistant {
|
||||
enum Status: Equatable {
|
||||
case checking
|
||||
case ready
|
||||
case unavailable(String)
|
||||
}
|
||||
|
||||
private(set) var status: Status = .checking
|
||||
private var session: LanguageModelSession?
|
||||
|
||||
init() { configure() }
|
||||
|
||||
private func configure() {
|
||||
switch SystemLanguageModel.default.availability {
|
||||
case .available:
|
||||
session = LanguageModelSession(
|
||||
tools: [BookmarkSearchTool()],
|
||||
instructions: """
|
||||
You are an assistant inside a bookmarks app. The user's saved bookmarks \
|
||||
are your only source of truth. To answer any question, call the \
|
||||
searchBookmarks tool to look things up — never rely on outside knowledge \
|
||||
or invent links. Ground every answer in the returned bookmarks, refer to \
|
||||
them by title, and include their URLs when relevant. If nothing relevant \
|
||||
is found, say so plainly. Keep answers concise.
|
||||
"""
|
||||
)
|
||||
status = .ready
|
||||
Log.assistant.info("On-device model available; session ready")
|
||||
case .unavailable(let reason):
|
||||
status = .unavailable(Self.message(for: reason))
|
||||
Log.assistant.notice("On-device model unavailable: \(String(describing: reason), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask a question; the model retrieves from Spotlight via the tool as needed.
|
||||
func ask(_ question: String) async throws -> String {
|
||||
guard let session else {
|
||||
Log.assistant.error("ask() with no session (model unavailable)")
|
||||
throw AssistantError.unavailable
|
||||
}
|
||||
Log.assistant.info("Ask: \(question, privacy: .public)")
|
||||
let start = Date()
|
||||
do {
|
||||
let answer = try await session.respond(to: question).content
|
||||
let ms = Int(Date().timeIntervalSince(start) * 1000)
|
||||
Log.assistant.info("Answered in \(ms, privacy: .public)ms, \(answer.count, privacy: .public) chars")
|
||||
return answer
|
||||
} catch {
|
||||
Log.assistant.error("Ask failed: \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func message(for reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
|
||||
switch reason {
|
||||
case .deviceNotEligible:
|
||||
return "This device doesn't support Apple Intelligence."
|
||||
case .appleIntelligenceNotEnabled:
|
||||
return "Turn on Apple Intelligence in Settings to ask your bookmarks."
|
||||
case .modelNotReady:
|
||||
return "The on-device model is still downloading. Try again shortly."
|
||||
@unknown default:
|
||||
return "On-device intelligence isn't available right now."
|
||||
}
|
||||
}
|
||||
|
||||
enum AssistantError: LocalizedError {
|
||||
case unavailable
|
||||
var errorDescription: String? { "On-device intelligence is unavailable." }
|
||||
}
|
||||
}
|
||||
37
Marks/Services/BookmarkSearchTool.swift
Normal file
37
Marks/Services/BookmarkSearchTool.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
|
||||
/// The tool the on-device model calls to look things up in the user's library.
|
||||
/// It is the bridge between `LanguageModelSession` (generation) and the
|
||||
/// Spotlight index (retrieval) — together they form on-device RAG over the
|
||||
/// private bookmark corpus.
|
||||
struct BookmarkSearchTool: Tool {
|
||||
let name = "searchBookmarks"
|
||||
let description = """
|
||||
Search the user's saved bookmarks. Returns the most relevant bookmarks with \
|
||||
their title, website, description, and URL. Call this for any question about \
|
||||
what the user has saved, read, or wants to find again.
|
||||
"""
|
||||
|
||||
@Generable
|
||||
struct Arguments {
|
||||
@Guide(description: "Keywords or a short phrase describing what to look for in the saved bookmarks")
|
||||
var query: String
|
||||
}
|
||||
|
||||
func call(arguments: Arguments) async throws -> String {
|
||||
Log.tool.info("searchBookmarks invoked query=\(arguments.query, privacy: .public)")
|
||||
let hits = await SpotlightBookmarkSearch.run(query: arguments.query, limit: 8)
|
||||
Log.tool.info("searchBookmarks returned \(hits.count, privacy: .public) bookmarks")
|
||||
guard !hits.isEmpty else {
|
||||
return "No bookmarks matched \"\(arguments.query)\"."
|
||||
}
|
||||
return hits.enumerated().map { idx, b in
|
||||
var line = "\(idx + 1). \(b.title)"
|
||||
if !b.host.isEmpty { line += " — \(b.host)" }
|
||||
if !b.description.isEmpty { line += "\n \(b.description)" }
|
||||
if !b.url.isEmpty { line += "\n \(b.url)" }
|
||||
return line
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -95,12 +95,13 @@ struct ClaudeService: Sendable {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
print("[AI] POST \(path)")
|
||||
Log.network.debug("POST \(path, privacy: .public)")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
print("[AI] POST \(path) → \(status)")
|
||||
Log.network.info("POST \(path, privacy: .public) → \(status, privacy: .public)")
|
||||
guard (200...299).contains(status) else {
|
||||
print("[AI] error: \(String(data: data, encoding: .utf8)?.prefix(200) ?? "")")
|
||||
let bodyText = String(data: data, encoding: .utf8)?.prefix(400) ?? ""
|
||||
Log.network.error("POST \(path, privacy: .public) → \(status, privacy: .public) body=\(bodyText, privacy: .public)")
|
||||
throw APIError.badStatus(status)
|
||||
}
|
||||
return data
|
||||
@@ -111,11 +112,13 @@ struct ClaudeService: Sendable {
|
||||
guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl }
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
print("[AI] GET \(path)")
|
||||
Log.network.debug("GET \(path, privacy: .public)")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
print("[AI] GET \(path) → \(status)")
|
||||
Log.network.info("GET \(path, privacy: .public) → \(status, privacy: .public)")
|
||||
guard (200...299).contains(status) else {
|
||||
let bodyText = String(data: data, encoding: .utf8)?.prefix(400) ?? ""
|
||||
Log.network.error("GET \(path, privacy: .public) → \(status, privacy: .public) body=\(bodyText, privacy: .public)")
|
||||
throw APIError.badStatus(status)
|
||||
}
|
||||
return data
|
||||
|
||||
43
Marks/Services/Log.swift
Normal file
43
Marks/Services/Log.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Organized, category-based logging for Marks, backed by `os.Logger`.
|
||||
///
|
||||
/// Unlike `print()` — which only reaches stdout and is invisible to
|
||||
/// `log collect` / Console.app — these entries are captured under the
|
||||
/// `com.magicive.marks` subsystem and can be filtered by category off-device.
|
||||
///
|
||||
/// Pull logs from a connected device:
|
||||
/// ```
|
||||
/// log collect --device-udid <UDID> --last 5m --output marks.logarchive
|
||||
/// log show marks.logarchive --info --debug \
|
||||
/// --predicate 'subsystem == "com.magicive.marks"'
|
||||
/// # one category:
|
||||
/// log show marks.logarchive --predicate \
|
||||
/// 'subsystem == "com.magicive.marks" AND category == "assistant"'
|
||||
/// ```
|
||||
///
|
||||
/// Privacy: `os.Logger` redacts interpolations to `<private>` by default. The
|
||||
/// on-device RAG path (`assistant`, `tool`, `spotlight`) logs queries/counts as
|
||||
/// `.public` so the feature is debuggable; bookmark *content* in the cloud
|
||||
/// enrichment path stays default-private.
|
||||
enum Log {
|
||||
private static let subsystem = "com.magicive.marks"
|
||||
|
||||
/// On-device RAG: model availability, questions asked, answers produced.
|
||||
static let assistant = Logger(subsystem: subsystem, category: "assistant")
|
||||
/// The `searchBookmarks` tool the on-device model calls.
|
||||
static let tool = Logger(subsystem: subsystem, category: "tool")
|
||||
/// Spotlight retrieval (`CSSearchQuery`) and indexing (`indexAppEntities`).
|
||||
static let spotlight = Logger(subsystem: subsystem, category: "spotlight")
|
||||
/// Cloud AI enrichment (summaries, tags) via the backend.
|
||||
static let ai = Logger(subsystem: subsystem, category: "ai")
|
||||
/// Bookmark sync / disk cache.
|
||||
static let sync = Logger(subsystem: subsystem, category: "sync")
|
||||
/// Backend HTTP primitives (requests, status codes, error bodies).
|
||||
static let network = Logger(subsystem: subsystem, category: "network")
|
||||
/// Anonymous device auth / token registration.
|
||||
static let auth = Logger(subsystem: subsystem, category: "auth")
|
||||
/// Product analytics event delivery.
|
||||
static let analytics = Logger(subsystem: subsystem, category: "analytics")
|
||||
}
|
||||
@@ -46,7 +46,7 @@ enum MarksAuth {
|
||||
let resp = try JSONDecoder().decode(Resp.self, from: data)
|
||||
UserDefaults.standard.set(resp.access_token, forKey: tokenKey)
|
||||
UserDefaults.standard.set(Date().addingTimeInterval(TimeInterval(resp.expires_in)), forKey: tokenExpiryKey)
|
||||
print("[Auth] Registered \(id.prefix(8))…")
|
||||
Log.auth.info("Registered device \(id.prefix(8), privacy: .public)…")
|
||||
return resp.access_token
|
||||
}
|
||||
}
|
||||
|
||||
70
Marks/Services/SpotlightBookmarkSearch.swift
Normal file
70
Marks/Services/SpotlightBookmarkSearch.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import CoreSpotlight
|
||||
|
||||
/// A bookmark retrieved from the Spotlight index, reduced to the fields the
|
||||
/// on-device model needs to ground an answer.
|
||||
struct RetrievedBookmark: Sendable {
|
||||
let title: String
|
||||
let host: String
|
||||
let description: String
|
||||
let url: String
|
||||
}
|
||||
|
||||
/// Retrieval half of on-device RAG: queries the Spotlight index that
|
||||
/// `SpotlightIndexer` populated and returns the best-matching bookmarks. The
|
||||
/// search runs entirely on-device against the app's own indexed entities.
|
||||
enum SpotlightBookmarkSearch {
|
||||
static func run(query rawQuery: String, limit: Int) async -> [RetrievedBookmark] {
|
||||
guard CSSearchableIndex.isIndexingAvailable() else {
|
||||
Log.spotlight.notice("Search skipped: indexing unavailable")
|
||||
return []
|
||||
}
|
||||
let queryString = makeQueryString(from: rawQuery)
|
||||
guard !queryString.isEmpty else { return [] }
|
||||
Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)")
|
||||
|
||||
let context = CSSearchQueryContext()
|
||||
context.fetchAttributes = ["title", "contentDescription", "keywords", "url"]
|
||||
let query = CSSearchQuery(queryString: queryString, queryContext: context)
|
||||
|
||||
var out: [RetrievedBookmark] = []
|
||||
do {
|
||||
for try await result in query.results {
|
||||
let a = result.item.attributeSet
|
||||
out.append(RetrievedBookmark(
|
||||
title: a.title ?? "Untitled",
|
||||
host: a.url?.host() ?? "",
|
||||
description: a.contentDescription ?? "",
|
||||
url: a.url?.absoluteString ?? ""
|
||||
))
|
||||
if out.count >= limit { break }
|
||||
}
|
||||
Log.spotlight.info("Search \"\(rawQuery, privacy: .public)\" → \(out.count, privacy: .public) hits")
|
||||
} catch {
|
||||
Log.spotlight.error("Search failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
query.cancel()
|
||||
return out
|
||||
}
|
||||
|
||||
/// Build a CoreSpotlight query string that OR-matches each significant token
|
||||
/// across title / description / keywords, case- and diacritic-insensitive.
|
||||
private static func makeQueryString(from raw: String) -> String {
|
||||
let tokens = raw
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.map { $0.lowercased() }
|
||||
.filter { $0.count >= 3 }
|
||||
let terms = (tokens.isEmpty ? [raw] : tokens).map(sanitize).filter { !$0.isEmpty }
|
||||
let fields = ["title", "contentDescription", "keywords"]
|
||||
let clauses = terms.flatMap { term in
|
||||
fields.map { "\($0) == \"*\(term)*\"cd" }
|
||||
}
|
||||
guard !clauses.isEmpty else { return "" }
|
||||
return "(" + clauses.joined(separator: " || ") + ")"
|
||||
}
|
||||
|
||||
/// Keep only alphanumerics so a token can't break the query string syntax.
|
||||
private static func sanitize(_ s: String) -> String {
|
||||
String(String.UnicodeScalarView(s.unicodeScalars.filter(CharacterSet.alphanumerics.contains)))
|
||||
}
|
||||
}
|
||||
40
Marks/Services/SpotlightIndexer.swift
Normal file
40
Marks/Services/SpotlightIndexer.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import CoreSpotlight
|
||||
import AppIntents
|
||||
|
||||
/// Pushes Linkding bookmarks into the system Spotlight index via the
|
||||
/// `IndexedEntity` conformance on `BookmarkEntity`.
|
||||
///
|
||||
/// This is the step that actually makes bookmarks discoverable: conforming to
|
||||
/// `IndexedEntity` only describes *how* an entity would be indexed (its
|
||||
/// `attributeSet`) — nothing reaches Spotlight until the entities are handed to
|
||||
/// `CSSearchableIndex`. Once indexed, bookmarks show up in Spotlight search and
|
||||
/// are reachable by Siri / Apple Intelligence.
|
||||
///
|
||||
/// All calls are best-effort: failures are logged, never surfaced. If indexing
|
||||
/// is unavailable or fails, search degrades but nothing else breaks.
|
||||
enum SpotlightIndexer {
|
||||
/// Insert or update the given bookmarks in the Spotlight index.
|
||||
static func index(_ bookmarks: [Bookmark]) async {
|
||||
guard CSSearchableIndex.isIndexingAvailable(), !bookmarks.isEmpty else { return }
|
||||
do {
|
||||
try await CSSearchableIndex.default()
|
||||
.indexAppEntities(bookmarks.map(BookmarkEntity.init(from:)))
|
||||
Log.spotlight.info("Indexed \(bookmarks.count, privacy: .public) bookmarks")
|
||||
} catch {
|
||||
Log.spotlight.error("Index failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove bookmarks from the index by id (e.g. after delete / archive).
|
||||
static func remove(ids: [Int]) async {
|
||||
guard CSSearchableIndex.isIndexingAvailable(), !ids.isEmpty else { return }
|
||||
do {
|
||||
try await CSSearchableIndex.default()
|
||||
.deleteAppEntities(identifiedBy: ids, ofType: BookmarkEntity.self)
|
||||
Log.spotlight.info("Removed \(ids.count, privacy: .public) bookmarks")
|
||||
} catch {
|
||||
Log.spotlight.error("Delete failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ targets:
|
||||
- path: Marks/Models/ServerConfig.swift
|
||||
- path: Marks/Services/LinkdingAPI.swift
|
||||
- path: Marks/Services/MarksAuth.swift
|
||||
- path: Marks/Services/Log.swift
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension
|
||||
|
||||
Reference in New Issue
Block a user