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 */; };
|
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 */,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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("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
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)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user