feat(ai): on-device RAG over bookmarks + Spotlight indexing + unified logging
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:
Krishna Kumar
2026-06-24 12:55:32 -05:00
parent e82e69c5f3
commit 96ea5fe6f6
14 changed files with 499 additions and 18 deletions

View File

@@ -12,17 +12,22 @@
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.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 */; }; 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.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 */; }; 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.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 */; }; 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; }; 706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; }; 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; }; 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; };
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; }; 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 */; }; 8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7A85A23A13D754F6A75E4D /* ShareView.swift */; };
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; }; 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; }; 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 */; }; 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; }; 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 */; }; AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; };
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; 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, ); }; }; 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 */; }; B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; };
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.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; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -232,10 +245,15 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */, 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */, 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */, 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
FB7728D15C17219ABFF3EFFE /* Log.swift */,
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -258,6 +276,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
217E6702DE1210AC38ED16D1 /* AskView.swift */,
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
@@ -460,6 +479,7 @@
files = ( files = (
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */, 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */, 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */, B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */, F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */,
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */, 8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */,
@@ -486,11 +506,14 @@
files = ( files = (
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */, AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */,
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */, FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */, 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */, 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
@@ -500,6 +523,7 @@
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */, DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */, EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
212F713DCC289C48087B79AE /* Log.swift in Sources */,
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */, 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */, E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */, 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
@@ -509,6 +533,8 @@
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */, EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */, 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */,
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */,
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */, B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */, 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */, E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */,

View File

@@ -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 // MARK: - App Shortcuts
struct MarksShortcuts: AppShortcutsProvider { struct MarksShortcuts: AppShortcutsProvider {

View File

@@ -18,10 +18,10 @@ enum Analytics {
let (_, response) = try await URLSession.shared.data(for: request) let (_, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status < 200 || status > 299 { if status < 200 || status > 299 {
print("[Analytics] \(event) → HTTP \(status)") Log.analytics.error("\(event, privacy: .public) → HTTP \(status, privacy: .public)")
} }
} catch { } catch {
print("[Analytics] \(event) failed: \(error.localizedDescription)") Log.analytics.error("\(event, privacy: .public) failed: \(error.localizedDescription, privacy: .public)")
} }
} }
} }

View 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." }
}
}

View 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")
}
}

View File

@@ -95,12 +95,13 @@ struct ClaudeService: Sendable {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body) 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 (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 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 { 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) throw APIError.badStatus(status)
} }
return data return data
@@ -111,11 +112,13 @@ struct ClaudeService: Sendable {
guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl } guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") 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 (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 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 { 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) throw APIError.badStatus(status)
} }
return data return data

43
Marks/Services/Log.swift Normal file
View 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")
}

View File

@@ -46,7 +46,7 @@ enum MarksAuth {
let resp = try JSONDecoder().decode(Resp.self, from: data) let resp = try JSONDecoder().decode(Resp.self, from: data)
UserDefaults.standard.set(resp.access_token, forKey: tokenKey) UserDefaults.standard.set(resp.access_token, forKey: tokenKey)
UserDefaults.standard.set(Date().addingTimeInterval(TimeInterval(resp.expires_in)), forKey: tokenExpiryKey) 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 return resp.access_token
} }
} }

View 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)))
}
}

View 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
View 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)
}
}

View File

@@ -59,6 +59,7 @@ struct BookmarksView: View {
@State private var browsingBookmark: Bookmark? @State private var browsingBookmark: Bookmark?
@State private var showFullPlayer = false @State private var showFullPlayer = false
@State private var showPodcastLibrary = false @State private var showPodcastLibrary = false
@State private var showAsk = false
@State private var readingProgress: [String: Double] = ReadingProgress.all() @State private var readingProgress: [String: Double] = ReadingProgress.all()
var body: some View { var body: some View {
@@ -95,6 +96,11 @@ struct BookmarksView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Menu { Menu {
Button {
showAsk = true
} label: {
Label("Ask Your Bookmarks", systemImage: "bubble.left.and.text.bubble.right")
}
Button { Button {
Task { await viewModel.generateSmartCollections() } Task { await viewModel.generateSmartCollections() }
showCollections = true showCollections = true
@@ -167,6 +173,9 @@ struct BookmarksView: View {
.sheet(isPresented: $showCollections) { .sheet(isPresented: $showCollections) {
CollectionsView(viewModel: viewModel) CollectionsView(viewModel: viewModel)
} }
.sheet(isPresented: $showAsk) {
AskView()
}
.sheet(isPresented: $showAddBookmark) { .sheet(isPresented: $showAddBookmark) {
AddBookmarkView(viewModel: viewModel) AddBookmarkView(viewModel: viewModel)
} }

View File

@@ -42,6 +42,7 @@ final class BookmarksViewModel {
WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map { WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map {
WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl) WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl)
}) })
Task { await SpotlightIndexer.index(bookmarks) }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }
@@ -57,6 +58,8 @@ final class BookmarksViewModel {
bookmarks.append(contentsOf: response.results) bookmarks.append(contentsOf: response.results)
nextPageUrl = response.next nextPageUrl = response.next
restoreAIData() restoreAIData()
let added = response.results
Task { await SpotlightIndexer.index(added) }
} catch { } catch {
self.error = error.localizedDescription 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 create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
let bookmark = try await api.createBookmark(create) let bookmark = try await api.createBookmark(create)
bookmarks.insert(bookmark, at: 0) 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 { 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 { do {
let (summary, aiTags) = try await claude.enrich(bookmark: bookmark) 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 { 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 return
} }
bookmarks[i].aiSummary = summary bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = aiTags bookmarks[i].aiTags = aiTags
saveAIData(for: bookmarks[i]) saveAIData(for: bookmarks[i])
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
} catch { } catch {
print("[AI] addBookmark: enrich FAILED \(error)") Log.ai.error("addBookmark enrich failed: \(error.localizedDescription, privacy: .public)")
self.error = "AI enrichment failed: \(error.localizedDescription)" self.error = "AI enrichment failed: \(error.localizedDescription)"
} }
} }
@@ -126,6 +132,7 @@ final class BookmarksViewModel {
do { do {
try await api.deleteBookmark(id: bookmark.id) try await api.deleteBookmark(id: bookmark.id)
bookmarks.removeAll { $0.id == bookmark.id } bookmarks.removeAll { $0.id == bookmark.id }
Task { await SpotlightIndexer.remove(ids: [bookmark.id]) }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }
@@ -135,6 +142,7 @@ final class BookmarksViewModel {
do { do {
try await api.archiveBookmark(id: bookmark.id) try await api.archiveBookmark(id: bookmark.id)
bookmarks.removeAll { $0.id == bookmark.id } bookmarks.removeAll { $0.id == bookmark.id }
Task { await SpotlightIndexer.remove(ids: [bookmark.id]) }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }
@@ -180,6 +188,8 @@ final class BookmarksViewModel {
bookmarks[i].aiSummary = summary bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = tags bookmarks[i].aiTags = tags
saveAIData(for: bookmarks[i]) saveAIData(for: bookmarks[i])
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
enrichmentProgress = Double(done + 1) / Double(toEnrich.count) enrichmentProgress = Double(done + 1) / Double(toEnrich.count)
} catch { } catch {
break break
@@ -196,23 +206,25 @@ final class BookmarksViewModel {
enrichTask = Task { [weak self] in enrichTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) } 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 { for i in indices {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
let claude = await MainActor.run(body: { self.claude }) let claude = await MainActor.run(body: { self.claude })
do { do {
let bm = await MainActor.run { bookmarks[i] } 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) 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 { await MainActor.run {
guard i < bookmarks.count else { return } guard i < bookmarks.count else { return }
bookmarks[i].aiSummary = summary bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = tags bookmarks[i].aiTags = tags
saveAIData(for: bookmarks[i]) saveAIData(for: bookmarks[i])
let enriched = bookmarks[i]
Task { await SpotlightIndexer.index([enriched]) }
} }
} catch { } catch {
print("[AI] startEnrichment: enrich FAILED id=\(i) \(error)") Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)")
break break
} }
} }
@@ -239,7 +251,7 @@ final class BookmarksViewModel {
let data = try Self.cacheEncoder.encode(list) let data = try Self.cacheEncoder.encode(list)
try data.write(to: cacheFileUrl, options: .atomic) try data.write(to: cacheFileUrl, options: .atomic)
} catch { } catch {
print("[Cache] saveToCache failed: \(error)") Log.sync.error("saveToCache failed: \(error.localizedDescription, privacy: .public)")
} }
} }

View File

@@ -89,6 +89,7 @@ targets:
- path: Marks/Models/ServerConfig.swift - path: Marks/Models/ServerConfig.swift
- path: Marks/Services/LinkdingAPI.swift - path: Marks/Services/LinkdingAPI.swift
- path: Marks/Services/MarksAuth.swift - path: Marks/Services/MarksAuth.swift
- path: Marks/Services/Log.swift
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension