diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 9f58419..f6d3089 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -9,11 +9,15 @@ /* Begin PBXBuildFile section */ 00B1EF932509721ADCD9CA59 /* APIClientDocumentTemplatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE93E83AA6CBE672E89C719 /* APIClientDocumentTemplatesTests.swift */; }; 015680D1BA27E22F59C2DCEB /* ShareLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD05B653363E3A794B1B1EE7 /* ShareLink.swift */; }; + 016599A41F91048F2AA1AF10 /* AIArtifactTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F54C1776AD4A8F204FA8A2BC /* AIArtifactTests.swift */; }; + 04058C277198B967609771BC /* AIPoweredTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1B944CA1DA2CC8C8EECFC0 /* AIPoweredTemplate.swift */; }; 05B7A32AAD6202D76C1CC975 /* DocumentSyncConflictTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */; }; + 06EF50D45957E434A1B0F9B8 /* APIClientAITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB1570DDE0B0F44F205DC71 /* APIClientAITests.swift */; }; 081D31E74FF9214FF1F7F185 /* SharedDocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D6C33FD59101503195F14F /* SharedDocumentView.swift */; }; 09037C64E9D988371F29AD75 /* ComposeImageUploaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */; }; 0B87CBFA04966C5AB9F5932C /* MessageLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4EA609D090D3BB9AB12228 /* MessageLinkView.swift */; }; 0C2A6EE423332702FD29DEBA /* Manrope.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 229B4152945DABCD6D88A59A /* Manrope.ttf */; }; + 10A357B7D843EDB843C1B640 /* AIArtifact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F919661375CA53C1864025A /* AIArtifact.swift */; }; 1102DD971BF4AC03027058B9 /* BlockedUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */; }; 153437B4818A068C9ADE32DE /* APIClientLinkMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907AC4BA9A6A5DF132AEF075 /* APIClientLinkMetadataTests.swift */; }; 1775C9B4C2CF08267AF3B138 /* WatchersListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5828367E38948582EF1DF2A /* WatchersListView.swift */; }; @@ -34,25 +38,31 @@ 4A2F1A2E2FB0B4BC857F9E30 /* DocumentSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */; }; 4AD79062F63869F2EA8B24E9 /* APIClientSharedListsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */; }; 4C5C8544DD7D81FA5C83A967 /* APIClientTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256FE9818A171E11DDE43140 /* APIClientTransport.swift */; }; + 54CB167C520E0D951820214F /* AIWritingAssistantButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A017C8D6CDA816772A8D4424 /* AIWritingAssistantButton.swift */; }; 55457DA5473AFDA9C7764EBF /* DocumentSyncConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */; }; 56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */; }; 5703F883D4E6186DB66E5833 /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */; }; 58151C239F0FDD01BD1BAEBB /* LinkedInPostingTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */; }; + 5AC90042DEF661C6A1550A0B /* AIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70A0129BD986243B7BECC74B /* AIRequest.swift */; }; 5B19E1BF3FA99B70C1F0153A /* APIClientSharingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8BE2B897589E1AEA55ACA75 /* APIClientSharingTests.swift */; }; 5F19AD96D4278D5B8D87154E /* MessagesInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471B0516327616EC36DE917 /* MessagesInboxView.swift */; }; + 5F81C584710958DCBF85F60E /* AIServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC10FAF5FB93F8317D2D91A /* AIServiceError.swift */; }; 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */; }; 6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */; }; 6A89622E299B0D172D5B5556 /* GitHubModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C337406C10876F331B1888E3 /* GitHubModelTests.swift */; }; 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */; }; 6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0470C00F1A0E215224120A1 /* ListWatcher.swift */; }; + 6DACF0399E254AAA27286324 /* AIPoweredTemplateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */; }; 7201B78F48B60376E06DB08E /* ILColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5914D730B4355F02E89D81 /* ILColor.swift */; }; 73777B938FC2AF9D06CE5AD4 /* APIClientGitHubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */; }; 745483850C410FDE3C23D44F /* ServerLimitsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */; }; 79878E2A806CDB0B1659C7C5 /* MarkdownEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */; }; + 798DC41883DC941055210F39 /* AISuggestionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DC6255396818F5BB523DE /* AISuggestionSheet.swift */; }; 7CD66F2490C7D2D4A3227E6A /* ILWebURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */; }; 7DC9B83A3D2C66E1F6F0AEC3 /* SharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADBF836F0B5BD6524776FE7 /* SharingView.swift */; }; 874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */; }; 8BEDD4D904328EC8027314DC /* SpaceGrotesk.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */; }; + 8C393D2AE39FAEFD773AAF53 /* AIComposerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF9EA6C9C5C613591038EA9A /* AIComposerLogicTests.swift */; }; 945250FBC238FFDDC4E526B7 /* ShareInvite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236670B73F2B603E5EB318D7 /* ShareInvite.swift */; }; 94E60D26B6EA1C38B91D9247 /* ServerLimitsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */; }; 98CC00030F4340A4BD821FC4 /* MarkdownBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B096C366108083D174366 /* MarkdownBlockTests.swift */; }; @@ -103,7 +113,9 @@ C184FAF9652A6EB2F4C00CEA /* APIClient+Limits.swift in Sources */ = {isa = PBXBuildFile; fileRef = A026223AD393B5E1A339E045 /* APIClient+Limits.swift */; }; C18C73C8A93572993F29D559 /* ImageUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */; }; C1D1E1F1A1B10001 /* Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D1E1F1A1B10002 /* Organization.swift */; }; + C3D82E611C8006A59B3FC5CD /* APIClient+AI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7318B0E3D687653B06626F64 /* APIClient+AI.swift */; }; C478109CF3BB06C22E121904 /* ReportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7800699C927CE3E133944E62 /* ReportSheet.swift */; }; + C4C3B533A4C5F76394318FAB /* AIServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 281721EB23841D6DD2F6EE58 /* AIServiceTests.swift */; }; C60FC4296F72CEDEEF394848 /* DMThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E55EFCB42C25CBC10B3942 /* DMThreadView.swift */; }; C6197F8F1B52DAE1B1D4A2F1 /* AppDeepLinkParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */; }; C68E4AC129D6026DF9FF5A5B /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA0B9A4EE76356EA1EF5785 /* UserSession.swift */; }; @@ -123,6 +135,7 @@ D0A1D0A1D0A1000B /* LinkedIdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */; }; D0A1D0A1D0A1000D /* OAuthSignInButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000E /* OAuthSignInButton.swift */; }; D0F35ACF98DDE854A8437C7D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */; }; + D51B991E570C29EC9D0FD3A4 /* AIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2A39A4CB7CD21E155C1AD77 /* AIService.swift */; }; D63C6301A24D618B74B1FAC6 /* SharedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E7411CA219F3E8EB351BAB /* SharedListView.swift */; }; D6AA238FF5D318D9C03FA6B5 /* PublicListDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */; }; DAC151BF93B6B223D43714F0 /* APIClientShareInvitesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44825D4863E64D2D9C026645 /* APIClientShareInvitesTests.swift */; }; @@ -133,16 +146,23 @@ E1642E8329DC145DA96584D4 /* LinkedInTargetModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */; }; E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */; }; E21121B51C15922CFBF92C43 /* DocumentSyncOutboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */; }; + E3626ADC322104C3A3BC8760 /* AIPoweredDocumentSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */; }; E51C9FF84350C46C947AC953 /* ServerLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0221B233F89B966D044C37FA /* ServerLimits.swift */; }; E64747D9845D6726CC1A1485 /* NetworkReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */; }; EA48487E20A6BECF67D3C060 /* ILWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */; }; EC636FE56A7CC64D7D01F64B /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5569B56DBD6747BB9587870 /* SessionsView.swift */; }; + ECBDA0498DFDBB84CC9A6215 /* AISeriesSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE18C95CBB9F759B7D44112B /* AISeriesSheet.swift */; }; + EF9A053E1DFC0D99766D0C5D /* AISeriesLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D154417430173A77A358118 /* AISeriesLoading.swift */; }; + F026A514BDE07AF64725D508 /* AIJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE452BC01A7461AF5199FFE6 /* AIJSON.swift */; }; + F187F186189A7C7D39EBC2FF /* AIStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F77792CB1F50F6A8AC6111 /* AIStatus.swift */; }; F211AD1D4EB93B9258F88CAC /* APIClientLinkedInTargetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC17A8A26D35CC755EE5988 /* APIClientLinkedInTargetsTests.swift */; }; F4B74219CE0CB90DAE6B7516 /* ShareInviteModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDCA7EDAA58C66663ACC010 /* ShareInviteModelTests.swift */; }; + F6CDA845B608759F92890B8E /* AIJSONTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE615A2103F89BAFD24418B /* AIJSONTests.swift */; }; F93C3A953A0B809D718F9DB5 /* PublicDocumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */; }; FA145F808445802781845261 /* DocumentLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */; }; FC004747AE60A6796DAD36AB /* APIClientDocumentSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468BAE51289ECDF42B84CE8B /* APIClientDocumentSyncTests.swift */; }; FCA5249B389CBC18040D6011 /* ComposeImageStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */; }; + FDF51BC81627FCB138474AFE /* AISeriesGeneratorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77237EF9E0A0D7E3D2B321CF /* AISeriesGeneratorSection.swift */; }; T1E5T1E5T1E50001 /* InterlinedListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50002 /* InterlinedListTests.swift */; }; T1E5T1E5T1E50003 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50004 /* MockURLSession.swift */; }; T1E5T1E5T1E50005 /* APIClientAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50006 /* APIClientAuthTests.swift */; }; @@ -202,6 +222,7 @@ 06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvitesSheet.swift; sourceTree = ""; }; 08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentLinkView.swift; sourceTree = ""; }; 09470364B5A0A559FDE3215F /* APIClientPushTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientPushTests.swift; sourceTree = ""; }; + 0AC10FAF5FB93F8317D2D91A /* AIServiceError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIServiceError.swift; sourceTree = ""; }; 0CC17A8A26D35CC755EE5988 /* APIClientLinkedInTargetsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLinkedInTargetsTests.swift; sourceTree = ""; }; 0EF29F5DC946D80A63000209 /* FollowListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FollowListView.swift; sourceTree = ""; }; 0FA0B9A4EE76356EA1EF5785 /* UserSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = ""; }; @@ -214,6 +235,7 @@ 229B4152945DABCD6D88A59A /* Manrope.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/Manrope.ttf; sourceTree = SOURCE_ROOT; }; 236670B73F2B603E5EB318D7 /* ShareInvite.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvite.swift; sourceTree = ""; }; 256FE9818A171E11DDE43140 /* APIClientTransport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTransport.swift; sourceTree = ""; }; + 281721EB23841D6DD2F6EE58 /* AIServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIServiceTests.swift; sourceTree = ""; }; 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageStrip.swift; sourceTree = ""; }; 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LinkedInPostingTarget.swift; sourceTree = ""; }; 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicListDetailView.swift; sourceTree = ""; }; @@ -224,6 +246,7 @@ 3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkReachability.swift; sourceTree = ""; }; 44825D4863E64D2D9C026645 /* APIClientShareInvitesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientShareInvitesTests.swift; sourceTree = ""; }; 468BAE51289ECDF42B84CE8B /* APIClientDocumentSyncTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentSyncTests.swift; sourceTree = ""; }; + 47F77792CB1F50F6A8AC6111 /* AIStatus.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIStatus.swift; sourceTree = ""; }; 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ILWebURLTests.swift; sourceTree = ""; }; 4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ILWebURL.swift; sourceTree = ""; }; 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharedResolverTests.swift; sourceTree = ""; }; @@ -234,16 +257,22 @@ 544F68F263670763D5E23C59 /* MessageDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageDetailView.swift; sourceTree = ""; }; 5802D2FF0552D510BB51D636 /* GitHub.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GitHub.swift; path = GitHub.swift; sourceTree = ""; }; 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessor.swift; sourceTree = ""; }; + 5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIPoweredTemplateView.swift; sourceTree = ""; }; 5CF8FDEBF56E4CAB421FD811 /* DocumentSyncOutbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncOutbox.swift; sourceTree = ""; }; + 5D154417430173A77A358118 /* AISeriesLoading.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AISeriesLoading.swift; sourceTree = ""; }; 644B1B09F84332A794680ED7 /* APIClientMessageByIdTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientMessageByIdTests.swift; sourceTree = ""; }; 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServerLimitsStore.swift; sourceTree = ""; }; 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientModerationTests.swift; sourceTree = ""; }; + 70A0129BD986243B7BECC74B /* AIRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIRequest.swift; sourceTree = ""; }; + 7318B0E3D687653B06626F64 /* APIClient+AI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+AI.swift"; sourceTree = ""; }; 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicBrowse.swift; sourceTree = ""; }; + 77237EF9E0A0D7E3D2B321CF /* AISeriesGeneratorSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AISeriesGeneratorSection.swift; sourceTree = ""; }; 7800699C927CE3E133944E62 /* ReportSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReportSheet.swift; sourceTree = ""; }; 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessorTests.swift; sourceTree = ""; }; 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/SpaceGrotesk.ttf; sourceTree = SOURCE_ROOT; }; 7EE93E83AA6CBE672E89C719 /* APIClientDocumentTemplatesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentTemplatesTests.swift; sourceTree = ""; }; 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploader.swift; sourceTree = ""; }; + 7F919661375CA53C1864025A /* AIArtifact.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIArtifact.swift; sourceTree = ""; }; 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownEditor.swift; sourceTree = ""; }; 8471B0516327616EC36DE917 /* MessagesInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesInboxView.swift; sourceTree = ""; }; 88C2C3577AEE0C73D51308DA /* DocumentSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSync.swift; sourceTree = ""; }; @@ -257,6 +286,7 @@ 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLimitsTests.swift; sourceTree = ""; }; 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicDocumentsView.swift; sourceTree = ""; }; 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharedListsTests.swift; sourceTree = ""; }; + A017C8D6CDA816772A8D4424 /* AIWritingAssistantButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIWritingAssistantButton.swift; sourceTree = ""; }; A026223AD393B5E1A339E045 /* APIClient+Limits.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Limits.swift"; sourceTree = ""; }; A1B1C1D1E1F10000 /* InterlinedList.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InterlinedList.app; sourceTree = BUILT_PRODUCTS_DIR; }; A1B1C1D1E1F10002 /* InterlinedListApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterlinedListApp.swift; sourceTree = ""; }; @@ -293,6 +323,7 @@ A3DF89EFAF63A9287656938C /* MarkdownView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownView.swift; sourceTree = ""; }; A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LinkedInTargetModelTests.swift; sourceTree = ""; }; A79A316417B99AB22D2FFDC1 /* JetBrainsMono.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/JetBrainsMono.ttf; sourceTree = SOURCE_ROOT; }; + AE452BC01A7461AF5199FFE6 /* AIJSON.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIJSON.swift; sourceTree = ""; }; B1C1D1E1F1A10002 /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; B1C1D1E1F1A10004 /* AppDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDataStore.swift; sourceTree = ""; }; B1C1D1E1F1A10006 /* SkeletonBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonBlock.swift; sourceTree = ""; }; @@ -301,6 +332,7 @@ B1C1D1E1F1A1000C /* DocumentSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentSkeletonView.swift; sourceTree = ""; }; B1C1D1E1F1A1000E /* ListItemFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItemFormView.swift; sourceTree = ""; }; B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientGapPhasesTests.swift; sourceTree = ""; }; + B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIPoweredDocumentSheet.swift; sourceTree = ""; }; B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TagDiscovery.swift; sourceTree = ""; }; B7D812B42F75DBEA52611BE0 /* FindPeopleView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FindPeopleView.swift; sourceTree = ""; }; BADBF836F0B5BD6524776FE7 /* SharingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharingView.swift; sourceTree = ""; }; @@ -308,6 +340,7 @@ BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncConflict.swift; sourceTree = ""; }; C0041BEAB46F9186FB5BF907 /* OrganizationsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OrganizationsView.swift; sourceTree = ""; }; C1D1E1F1A1B10002 /* Organization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Organization.swift; sourceTree = ""; }; + C2A39A4CB7CD21E155C1AD77 /* AIService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIService.swift; sourceTree = ""; }; C337406C10876F331B1888E3 /* GitHubModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GitHubModelTests.swift; path = GitHubModelTests.swift; sourceTree = ""; }; C470172F5310397C2E6195BC /* SharedResolution.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedResolution.swift; sourceTree = ""; }; C5C861AD11BBD66C75EB3EEE /* DocumentSyncModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncModelTests.swift; sourceTree = ""; }; @@ -324,14 +357,21 @@ D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploaderTests.swift; sourceTree = ""; }; D664CCA80BB084AF101113F8 /* APIClient+SharedLists.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+SharedLists.swift"; sourceTree = ""; }; DCDCA7EDAA58C66663ACC010 /* ShareInviteModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInviteModelTests.swift; sourceTree = ""; }; + DCE615A2103F89BAFD24418B /* AIJSONTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIJSONTests.swift; sourceTree = ""; }; + DE18C95CBB9F759B7D44112B /* AISeriesSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AISeriesSheet.swift; sourceTree = ""; }; + DE1B944CA1DA2CC8C8EECFC0 /* AIPoweredTemplate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIPoweredTemplate.swift; sourceTree = ""; }; + E16DC6255396818F5BB523DE /* AISuggestionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AISuggestionSheet.swift; sourceTree = ""; }; E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BlockedUsersView.swift; sourceTree = ""; }; E5828367E38948582EF1DF2A /* WatchersListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchersListView.swift; sourceTree = ""; }; E6A3F2C860C6B16F84CFFE12 /* APIClientDirectMessagesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientDirectMessagesTests.swift; sourceTree = ""; }; ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = ""; }; EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DirectMessageModelTests.swift; sourceTree = ""; }; + EEB1570DDE0B0F44F205DC71 /* APIClientAITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientAITests.swift; sourceTree = ""; }; + EF9EA6C9C5C613591038EA9A /* AIComposerLogicTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIComposerLogicTests.swift; sourceTree = ""; }; F0470C00F1A0E215224120A1 /* ListWatcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListWatcher.swift; sourceTree = ""; }; F379C02B2FCEB9440069B81C /* InterlinedList.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = InterlinedList.xctestplan; sourceTree = ""; }; F3E6216F2FFB4666000D153A /* InterlinedList.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = InterlinedList.entitlements; sourceTree = ""; }; + F54C1776AD4A8F204FA8A2BC /* AIArtifactTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIArtifactTests.swift; sourceTree = ""; }; F5569B56DBD6747BB9587870 /* SessionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SessionsView.swift; sourceTree = ""; }; F78B096C366108083D174366 /* MarkdownBlockTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownBlockTests.swift; sourceTree = ""; }; FC5914D730B4355F02E89D81 /* ILColor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterlinedList/Services/ILColor.swift; sourceTree = SOURCE_ROOT; }; @@ -464,6 +504,13 @@ B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */, C470172F5310397C2E6195BC /* SharedResolution.swift */, 0221B233F89B966D044C37FA /* ServerLimits.swift */, + AE452BC01A7461AF5199FFE6 /* AIJSON.swift */, + 7F919661375CA53C1864025A /* AIArtifact.swift */, + 47F77792CB1F50F6A8AC6111 /* AIStatus.swift */, + 0AC10FAF5FB93F8317D2D91A /* AIServiceError.swift */, + 70A0129BD986243B7BECC74B /* AIRequest.swift */, + 5D154417430173A77A358118 /* AISeriesLoading.swift */, + DE1B944CA1DA2CC8C8EECFC0 /* AIPoweredTemplate.swift */, ); path = Models; sourceTree = ""; @@ -491,6 +538,8 @@ D664CCA80BB084AF101113F8 /* APIClient+SharedLists.swift */, A026223AD393B5E1A339E045 /* APIClient+Limits.swift */, 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */, + 7318B0E3D687653B06626F64 /* APIClient+AI.swift */, + C2A39A4CB7CD21E155C1AD77 /* AIService.swift */, ); path = Services; sourceTree = ""; @@ -552,6 +601,12 @@ 03D6C33FD59101503195F14F /* SharedDocumentView.swift */, 38E7411CA219F3E8EB351BAB /* SharedListView.swift */, BADBF836F0B5BD6524776FE7 /* SharingView.swift */, + E16DC6255396818F5BB523DE /* AISuggestionSheet.swift */, + A017C8D6CDA816772A8D4424 /* AIWritingAssistantButton.swift */, + DE18C95CBB9F759B7D44112B /* AISeriesSheet.swift */, + 77237EF9E0A0D7E3D2B321CF /* AISeriesGeneratorSection.swift */, + 5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */, + B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */, ); path = Views; sourceTree = ""; @@ -613,6 +668,7 @@ 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */, 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */, 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */, + EEB1570DDE0B0F44F205DC71 /* APIClientAITests.swift */, ); path = APIClientTests; sourceTree = ""; @@ -641,6 +697,9 @@ 956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */, 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */, DCDCA7EDAA58C66663ACC010 /* ShareInviteModelTests.swift */, + DCE615A2103F89BAFD24418B /* AIJSONTests.swift */, + F54C1776AD4A8F204FA8A2BC /* AIArtifactTests.swift */, + EF9EA6C9C5C613591038EA9A /* AIComposerLogicTests.swift */, ); path = ModelTests; sourceTree = ""; @@ -656,6 +715,7 @@ D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */, 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */, D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */, + 281721EB23841D6DD2F6EE58 /* AIServiceTests.swift */, ); path = ServiceTests; sourceTree = ""; @@ -857,6 +917,21 @@ E51C9FF84350C46C947AC953 /* ServerLimits.swift in Sources */, C184FAF9652A6EB2F4C00CEA /* APIClient+Limits.swift in Sources */, 745483850C410FDE3C23D44F /* ServerLimitsStore.swift in Sources */, + F026A514BDE07AF64725D508 /* AIJSON.swift in Sources */, + 10A357B7D843EDB843C1B640 /* AIArtifact.swift in Sources */, + F187F186189A7C7D39EBC2FF /* AIStatus.swift in Sources */, + 5F81C584710958DCBF85F60E /* AIServiceError.swift in Sources */, + 5AC90042DEF661C6A1550A0B /* AIRequest.swift in Sources */, + EF9A053E1DFC0D99766D0C5D /* AISeriesLoading.swift in Sources */, + 04058C277198B967609771BC /* AIPoweredTemplate.swift in Sources */, + C3D82E611C8006A59B3FC5CD /* APIClient+AI.swift in Sources */, + D51B991E570C29EC9D0FD3A4 /* AIService.swift in Sources */, + 798DC41883DC941055210F39 /* AISuggestionSheet.swift in Sources */, + 54CB167C520E0D951820214F /* AIWritingAssistantButton.swift in Sources */, + ECBDA0498DFDBB84CC9A6215 /* AISeriesSheet.swift in Sources */, + FDF51BC81627FCB138474AFE /* AISeriesGeneratorSection.swift in Sources */, + 6DACF0399E254AAA27286324 /* AIPoweredTemplateView.swift in Sources */, + E3626ADC322104C3A3BC8760 /* AIPoweredDocumentSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -938,6 +1013,11 @@ 4AD79062F63869F2EA8B24E9 /* APIClientSharedListsTests.swift in Sources */, DB762CA98AEE01D098940CED /* APIClientLimitsTests.swift in Sources */, 94E60D26B6EA1C38B91D9247 /* ServerLimitsStoreTests.swift in Sources */, + 06EF50D45957E434A1B0F9B8 /* APIClientAITests.swift in Sources */, + F6CDA845B608759F92890B8E /* AIJSONTests.swift in Sources */, + 016599A41F91048F2AA1AF10 /* AIArtifactTests.swift in Sources */, + 8C393D2AE39FAEFD773AAF53 /* AIComposerLogicTests.swift in Sources */, + C4C3B533A4C5F76394318FAB /* AIServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/Models/AIArtifact.swift b/InterlinedList/Models/AIArtifact.swift new file mode 100644 index 0000000..cb940f9 --- /dev/null +++ b/InterlinedList/Models/AIArtifact.swift @@ -0,0 +1,254 @@ +// +// AIArtifact.swift +// InterlinedList +// + +import Foundation + +/// The AI features exposed by `/api/ai/{suggest,generate}`. Raw values are the +/// wire `feature` strings. +enum AIFeature: String, Codable, Equatable { + case writingAssist = "writing_assist" + case poweredTemplate = "powered_template" + case poweredDocument = "powered_document" + case messageSeries = "message_series" + case articleSeries = "article_series" +} + +/// Presenting a feature-driven sheet needs identity. +extension AIFeature: Identifiable { + var id: String { rawValue } +} + +/// Writing Assistant actions (`context.action`). +enum AIWritingAction: String, CaseIterable, Identifiable, Equatable { + case rewrite, tighten, expand, grammar, thread, tags + + var id: String { rawValue } + + var label: String { + switch self { + case .rewrite: return "Rewrite" + case .tighten: return "Tighten to fit" + case .expand: return "Expand" + case .grammar: return "Fix grammar" + case .thread: return "Split into thread" + case .tags: return "Suggest tags" + } + } + + var systemImage: String { + switch self { + case .rewrite: return "wand.and.stars" + case .tighten: return "arrow.down.right.and.arrow.up.left" + case .expand: return "arrow.up.left.and.arrow.down.right" + case .grammar: return "text.badge.checkmark" + case .thread: return "list.number" + case .tags: return "number" + } + } +} + +/// Powered Document modes (`context.mode`). +enum AIDocumentMode: String, CaseIterable, Identifiable, Equatable { + case article + case fromList = "from_list" + case fromArticle = "from_article" + case researchUrl = "research_url" + + var id: String { rawValue } + + var label: String { + switch self { + case .article: return "Article" + case .fromList: return "Derived From List" + case .fromArticle: return "Derived From Article" + case .researchUrl: return "Research URL" + } + } + + var systemImage: String { + switch self { + case .article: return "doc.text" + case .fromList: return "list.bullet.rectangle" + case .fromArticle: return "doc.on.doc" + case .researchUrl: return "link" + } + } +} + +// MARK: - Artifact payloads + +struct AIDocumentArtifact: Codable, Equatable { + var title: String + var markdown: String + var outline: [String]? + var isPublic: Bool? +} + +struct AIMessageSeriesItem: Codable, Equatable, Identifiable { + var order: Int + var content: String + var scheduledAt: String? + var crossPostTargets: [String]? + + var id: Int { order } +} + +struct AIMessageSeriesArtifact: Codable, Equatable { + var listTitle: String + var items: [AIMessageSeriesItem] +} + +struct AIDocSeriesDocument: Codable, Equatable, Identifiable { + var order: Int + var title: String + var outline: [String]? + var markdown: String? + + var id: Int { order } +} + +struct AIDocSeriesArtifact: Codable, Equatable { + var folderTitle: String + var documents: [AIDocSeriesDocument] +} + +/// One column of a generated list schema, read out of the DSL for the preview. +/// The DSL itself is never rebuilt from these — it round-trips verbatim. +struct AIListField: Identifiable, Equatable { + let id: String + let label: String + let type: String + let isRequired: Bool + let options: [String] +} + +struct AIListArtifact: Codable, Equatable { + var title: String + var description: String? + /// The model-authored DSL schema, kept verbatim — the server re-validates it + /// at generate time and rejects a schema whose field keys were rewritten. + var dsl: AIJSON + var rows: [AIJSON]? + + /// Columns in `dsl.fields` order, for the preview. + var fields: [AIListField] { + guard let entries = dsl.objectValue?["fields"]?.arrayValue else { return [] } + return entries.enumerated().compactMap { index, entry in + guard let object = entry.objectValue else { return nil } + let key = object["key"]?.stringValue + guard let label = object["label"]?.stringValue ?? key else { return nil } + return AIListField( + id: key ?? "field-\(index)", + label: label, + type: object["type"]?.stringValue ?? "text", + isRequired: object["required"] == .bool(true), + options: object["options"]?.arrayValue?.compactMap { $0.stringValue } ?? [] + ) + } + } + + /// Field labels in `dsl.fields` order. + var fieldLabels: [String] { fields.map { $0.label } } + + /// A starter row rendered as `label: value` pairs in schema order. + func rowSummary(_ row: AIJSON) -> [(label: String, value: String)] { + guard let object = row.objectValue else { return [] } + return fields.compactMap { field in + guard let value = object[field.id], value != .null else { return nil } + let rendered = value.displayString + return rendered.isEmpty ? nil : (field.label, rendered) + } + } +} + +/// The typed envelope `/suggest` returns and `/generate` accepts back. The wire +/// form is the payload's fields plus a `kind` discriminator. +enum AIArtifact: Codable, Equatable { + case message(String) + case tags([String]) + case thread([String]) + case document(AIDocumentArtifact) + case messageSeries(AIMessageSeriesArtifact) + case docSeries(AIDocSeriesArtifact) + case list(AIListArtifact) + + var kind: String { + switch self { + case .message: return "message" + case .tags: return "tags" + case .thread: return "thread" + case .document: return "document" + case .messageSeries: return "message_series" + case .docSeries: return "doc_series" + case .list: return "list" + } + } + + private enum CodingKeys: String, CodingKey { + case kind, content, tags, parts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case "message": + self = .message(try container.decode(String.self, forKey: .content)) + case "tags": + self = .tags(try container.decode([String].self, forKey: .tags)) + case "thread": + self = .thread(try container.decode([String].self, forKey: .parts)) + case "document": + self = .document(try AIDocumentArtifact(from: decoder)) + case "message_series": + self = .messageSeries(try AIMessageSeriesArtifact(from: decoder)) + case "doc_series": + self = .docSeries(try AIDocSeriesArtifact(from: decoder)) + case "list": + self = .list(try AIListArtifact(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Unknown artifact kind \(kind)" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind, forKey: .kind) + switch self { + case .message(let content): + try container.encode(content, forKey: .content) + case .tags(let tags): + try container.encode(tags, forKey: .tags) + case .thread(let parts): + try container.encode(parts, forKey: .parts) + case .document(let payload): + try payload.encode(to: encoder) + case .messageSeries(let payload): + try payload.encode(to: encoder) + case .docSeries(let payload): + try payload.encode(to: encoder) + case .list(let payload): + try payload.encode(to: encoder) + } + } +} + +// MARK: - Generate result + +/// What `/api/ai/generate` created. Exactly one shape arrives per feature: a +/// list, a document, an article-series folder, or a batch of scheduled messages. +struct AICreated: Codable, Equatable { + var listId: String? + var documentId: String? + var folderId: String? + var documentIds: [String]? + var scheduledMessageIds: [String]? + var firstScheduledAt: String? + var lastScheduledAt: String? +} diff --git a/InterlinedList/Models/AIJSON.swift b/InterlinedList/Models/AIJSON.swift new file mode 100644 index 0000000..51ab231 --- /dev/null +++ b/InterlinedList/Models/AIJSON.swift @@ -0,0 +1,120 @@ +// +// AIJSON.swift +// InterlinedList +// + +import Foundation + +/// A lossless JSON value. +/// +/// The AI artifact envelope carries model-authored sub-objects — a Powered +/// Template's DSL schema and its starter rows — that must survive the round trip +/// from `POST /api/ai/suggest` back into `POST /api/ai/generate` unchanged: the +/// server re-validates the confirmed artifact, and a renamed field key +/// (`scheduled_at` → `scheduledAt`) fails that check. `JSONValue` in `List.swift` +/// is scalar-only and can't carry them, and the shared `APIClient` decoder's +/// `convertFromSnakeCase` is exactly the mangling to avoid — the AI endpoints get +/// their own verbatim coders. +enum AIJSON: Codable, Equatable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([AIJSON]) + case object([String: AIJSON]) + case null + + var stringValue: String? { + if case .string(let s) = self { return s } + return nil + } + + var objectValue: [String: AIJSON]? { + if case .object(let o) = self { return o } + return nil + } + + var arrayValue: [AIJSON]? { + if case .array(let a) = self { return a } + return nil + } + + /// Flat rendering for a preview row. Arrays join with commas; objects and + /// null render empty, since neither belongs in a single-line cell. + var displayString: String { + switch self { + case .string(let s): return s + case .int(let i): return String(i) + case .double(let d): return d.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(d)) : String(d) + case .bool(let b): return b ? "Yes" : "No" + case .array(let a): return a.map { $0.displayString }.joined(separator: ", ") + case .object, .null: return "" + } + } + + private struct DynamicKey: CodingKey { + let stringValue: String + var intValue: Int? { nil } + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { nil } + } + + init(from decoder: Decoder) throws { + if let container = try? decoder.container(keyedBy: DynamicKey.self) { + var object: [String: AIJSON] = [:] + for key in container.allKeys { + object[key.stringValue] = try container.decode(AIJSON.self, forKey: key) + } + self = .object(object) + return + } + if var container = try? decoder.unkeyedContainer() { + var items: [AIJSON] = [] + while !container.isAtEnd { + items.append(try container.decode(AIJSON.self)) + } + self = .array(items) + return + } + let single = try decoder.singleValueContainer() + if single.decodeNil() { self = .null; return } + if let b = try? single.decode(Bool.self) { self = .bool(b); return } + // Int before Double so whole numbers re-encode as `1`, not `1.0`. + if let i = try? single.decode(Int.self) { self = .int(i); return } + if let d = try? single.decode(Double.self) { self = .double(d); return } + if let s = try? single.decode(String.self) { self = .string(s); return } + throw DecodingError.dataCorruptedError( + in: single, + debugDescription: "Unsupported JSON value" + ) + } + + func encode(to encoder: Encoder) throws { + switch self { + case .object(let object): + var container = encoder.container(keyedBy: DynamicKey.self) + for (key, value) in object { + guard let codingKey = DynamicKey(stringValue: key) else { continue } + try container.encode(value, forKey: codingKey) + } + case .array(let items): + var container = encoder.unkeyedContainer() + for item in items { try container.encode(item) } + case .string(let s): + var c = encoder.singleValueContainer() + try c.encode(s) + case .int(let i): + var c = encoder.singleValueContainer() + try c.encode(i) + case .double(let d): + var c = encoder.singleValueContainer() + try c.encode(d) + case .bool(let b): + var c = encoder.singleValueContainer() + try c.encode(b) + case .null: + var c = encoder.singleValueContainer() + try c.encodeNil() + } + } +} diff --git a/InterlinedList/Models/AIPoweredTemplate.swift b/InterlinedList/Models/AIPoweredTemplate.swift new file mode 100644 index 0000000..27132f9 --- /dev/null +++ b/InterlinedList/Models/AIPoweredTemplate.swift @@ -0,0 +1,57 @@ +// +// AIPoweredTemplate.swift +// InterlinedList +// + +import Foundation + +/// A Powered Template the AI personalizes into a list schema. +/// +/// `templateKey` is a prompt hint the server folds into the instruction — it is +/// optional and unvalidated, so this client-side gallery mirrors the server's +/// `POWERED_TEMPLATES` for presentation only. Picking none is valid: the model +/// then works from the description alone. +struct AIPoweredTemplate: Identifiable, Equatable { + let id: String + let name: String + let summary: String + let icon: String + let previewChips: [String] + + var promptPlaceholder: String { + "e.g. \(examplePrompt)" + } + + private var examplePrompt: String { + switch id { + case "messages_campaign": return "Launch week posts for the new release, across Bluesky and LinkedIn" + case "article_series": return "A four-part series on migrating a Rails app to Postgres 17" + case "software_workload": return "Backlog for the payments rewrite, with estimates and blockers" + default: return "Track conference talks: event, date, status, slides URL" + } + } + + static let gallery: [AIPoweredTemplate] = [ + AIPoweredTemplate( + id: "messages_campaign", + name: "Messages Campaign", + summary: "Plan and schedule a set of cross-posted messages around a single campaign.", + icon: "📣", + previewChips: ["Message", "Channels", "Status", "Scheduled At"] + ), + AIPoweredTemplate( + id: "article_series", + name: "Article Series", + summary: "Outline a multi-part article or blog series from idea through publication.", + icon: "📰", + previewChips: ["Title", "Part", "Status", "Angle"] + ), + AIPoweredTemplate( + id: "software_workload", + name: "Software Workload", + summary: "Track software work items across a backlog with type, status, and estimates.", + icon: "⚙️", + previewChips: ["Item", "Type", "Status", "Estimate"] + ), + ] +} diff --git a/InterlinedList/Models/AIRequest.swift b/InterlinedList/Models/AIRequest.swift new file mode 100644 index 0000000..8ba06dc --- /dev/null +++ b/InterlinedList/Models/AIRequest.swift @@ -0,0 +1,102 @@ +// +// AIRequest.swift +// InterlinedList +// + +import Foundation + +/// `context` on `POST /api/ai/suggest`. Every field is optional and only the +/// ones a feature reads are sent; the server clamps or ignores the rest. +struct AIContext: Encodable, Equatable { + /// Powered Template: which base template to personalize. + var templateKey: String? + /// Powered Document mode. + var mode: String? + var listId: String? + var documentId: String? + var url: String? + /// Writing Assistant action. + var action: String? + var targetPlatform: String? + /// Series sizing hints (clamped server-side). + var count: Int? + var spacingMinutes: Int? + /// Message Series: the composer's live cross-post channel labels, used + /// server-side to size each generated message to the tightest service limit. + var channels: [String]? + + static func writingAssist(_ action: AIWritingAction) -> AIContext { + AIContext(action: action.rawValue) + } + + static func poweredTemplate(templateKey: String?) -> AIContext { + AIContext(templateKey: templateKey) + } + + static func messageSeries(channels: [String], count: Int? = nil) -> AIContext { + AIContext(count: count, channels: channels) + } + + static func poweredDocument(mode: AIDocumentMode, listId: String? = nil, + documentId: String? = nil, url: String? = nil) -> AIContext { + AIContext(mode: mode.rawValue, listId: listId, documentId: documentId, url: url) + } +} + +/// The composer's live cross-post selection, forwarded verbatim to +/// `/api/ai/generate` so a "schedule immediately" message series posts to exactly +/// the accounts the compose form has toggled on. Sanitized server-side against +/// the caller's own connected accounts. +struct AIComposerCrossPost: Encodable, Equatable { + var crossPostToBluesky: Bool? + var selectedMastodonIds: [String]? + var crossPostToTwitter: Bool? + var crossPostToLinkedIn: Bool? + var selectedLinkedInTargets: [LinkedInTarget]? + var linkedInLinkAsFirstComment: Bool? + + var isEmpty: Bool { + crossPostToBluesky != true + && crossPostToTwitter != true + && crossPostToLinkedIn != true + && (selectedMastodonIds?.isEmpty ?? true) + } + + /// The `messages_campaign` channel labels this selection implies. Mirrors the + /// server's `channelLabelsFromSelection` so the sizing note the user reads + /// matches the limit the model is actually given. + var channelLabels: [String] { + var labels: [String] = [] + if crossPostToBluesky == true { labels.append("Bluesky") } + if let ids = selectedMastodonIds, !ids.isEmpty { labels.append("Mastodon") } + if crossPostToLinkedIn == true { labels.append("LinkedIn") } + if crossPostToTwitter == true { labels.append("X/Twitter") } + return labels + } +} + +/// Per-service post character limits, mirroring `lib/crosspost/char-limits.ts`. +/// Drives the "sized to fit N characters" note on the Message Series preview. +enum AICrossPostLimits { + static let bluesky = 300 + static let mastodon = 500 + static let linkedIn = 3000 + static let twitter = 280 + + private static func limit(forLabel label: String) -> Int? { + switch label.trimmingCharacters(in: .whitespaces).lowercased() { + case "bluesky": return bluesky + case "mastodon": return mastodon + case "linkedin": return linkedIn + case "x/twitter", "x", "twitter": return twitter + default: return nil + } + } + + /// The tightest limit across the selected channels, or `fallback` when none + /// of them maps to a message cross-post channel. + static func minCharLimit(labels: [String], fallback: Int) -> Int { + let limits = labels.compactMap(limit(forLabel:)) + return limits.min() ?? fallback + } +} diff --git a/InterlinedList/Models/AISeriesLoading.swift b/InterlinedList/Models/AISeriesLoading.swift new file mode 100644 index 0000000..ccd007c --- /dev/null +++ b/InterlinedList/Models/AISeriesLoading.swift @@ -0,0 +1,38 @@ +// +// AISeriesLoading.swift +// InterlinedList +// + +import Foundation + +/// Copy and step logic for the series-generation wait. The suggest call reports +/// no real progress and routinely runs tens of seconds, so a rotating caption is +/// what separates "working" from "hung". Pure, so the clamping is testable. +enum AISeriesLoading { + static func captions(for feature: AIFeature) -> [String] { + switch feature { + case .messageSeries: + return ["Reading your draft", "Planning the posts", "Writing each message", "Polishing the series"] + case .articleSeries: + return ["Reading your draft", "Outlining the articles", "Drafting each piece", "Polishing the series"] + default: + return ["Working"] + } + } + + static func title(for feature: AIFeature) -> String { + switch feature { + case .messageSeries: return "Generating your message series…" + case .articleSeries: return "Generating your article series…" + default: return "Generating…" + } + } + + /// Advance one caption every `step` seconds, clamped to the last one so the + /// sequence settles instead of implying endless work. + static func stepIndex(elapsed: TimeInterval, captionCount: Int, step: TimeInterval = 2.5) -> Int { + guard captionCount > 0 else { return 0 } + let raw = Int(max(0, elapsed) / step) + return min(raw, captionCount - 1) + } +} diff --git a/InterlinedList/Models/AIServiceError.swift b/InterlinedList/Models/AIServiceError.swift new file mode 100644 index 0000000..5d85a60 --- /dev/null +++ b/InterlinedList/Models/AIServiceError.swift @@ -0,0 +1,112 @@ +// +// AIServiceError.swift +// InterlinedList +// + +import Foundation + +/// Typed mapping of the `/api/ai/*` error contract (`{ error, code }` + status). +/// +/// AI is a subscriber entitlement served from the app's own server-side provider +/// key — there is no user-supplied key — so `no_provider_configured` is a server +/// outage, not a user-fixable state, and no case here may steer the user toward +/// a purchase (App Store Guideline 3.1.1). Raw backend copy is never surfaced +/// for the subscription gate. +enum AIServiceError: Error, Equatable { + /// 401 — the session token was rejected. Views route this to + /// `AuthState.handleUnauthorized()`, which re-validates before logging out. + case unauthorized + /// 403 `subscription_required` — shouldn't reach a gated control; if it + /// does, hide the affordance and refresh `/status`. + case subscriptionRequired + /// 409 `no_provider_configured` — the server has no provider key or the + /// provider is down. Transient; hide the control for the session. + case unavailable + /// 422 `invalid_input` — the request failed the server's own size/shape + /// checks. The message is safe to show (word limits). + case invalidInput(String) + /// 422 `invalid_ai_output` / `refused` — the model returned junk or declined. + case invalidOutput + /// 429 `quota_exceeded` — the 50/day budget is spent. + case quotaExceeded + /// 429 `rate_limited` — short-window burst limit; honor `Retry-After`. + case rateLimited(retryAfter: TimeInterval?) + /// 5xx `provider_error` — upstream failure. + case providerError + /// Transport failure, malformed response, or an unmapped status. + case transport + + /// Maps an HTTP status, the `{ error, code }` body, and the `Retry-After` + /// header onto a case. The whole wire-to-case mapping lives here. + init(status: Int, code: String?, message: String? = nil, retryAfter: TimeInterval? = nil) { + switch code { + case "unauthorized": self = .unauthorized + case "subscription_required": self = .subscriptionRequired + case "no_provider_configured": self = .unavailable + case "invalid_input": self = .invalidInput(message ?? "That request wasn't accepted.") + case "invalid_ai_output", "refused": self = .invalidOutput + case "quota_exceeded": self = .quotaExceeded + case "rate_limited": self = .rateLimited(retryAfter: retryAfter) + case "provider_error": self = .providerError + default: + // No code, or one this build doesn't know — fall back to the status. + switch status { + case 401: self = .unauthorized + case 403: self = .subscriptionRequired + case 409: self = .unavailable + case 422: self = .invalidOutput + case 429: self = .rateLimited(retryAfter: retryAfter) + case 500...599: self = .providerError + default: self = .transport + } + } + } + + /// Neutral in-app copy. Never mentions price, subscriptions, or provider keys. + var userMessage: String { + switch self { + case .unauthorized: + return "Your session expired. Sign in again to continue." + case .subscriptionRequired: + return "AI isn't available on this account." + case .unavailable: + return "AI is unavailable right now. Try again later." + case .invalidInput(let message): + return message + case .invalidOutput: + return "The assistant returned an unusable result. Try again." + case .quotaExceeded: + return "You've reached today's AI limit. It resets tomorrow." + case .rateLimited(let retryAfter): + guard let seconds = retryAfter, seconds > 0 else { + return "Too many AI requests. Try again in a moment." + } + return "Too many AI requests. Try again in \(Int(seconds.rounded()))s." + case .providerError: + return "The AI request failed. Try again." + case .transport: + return "Connection failed. Please try again." + } + } + + /// Whether re-running the same request is worth offering. + var isRetryable: Bool { + switch self { + case .invalidOutput, .providerError, .transport, .rateLimited: + return true + case .unauthorized, .subscriptionRequired, .unavailable, .invalidInput, .quotaExceeded: + return false + } + } + + /// Whether the affordance should disappear for the rest of the session: the + /// entitlement isn't there, or the server can't serve AI at all. + var hidesAffordance: Bool { + switch self { + case .subscriptionRequired, .unavailable: + return true + default: + return false + } + } +} diff --git a/InterlinedList/Models/AIStatus.swift b/InterlinedList/Models/AIStatus.swift new file mode 100644 index 0000000..4312753 --- /dev/null +++ b/InterlinedList/Models/AIStatus.swift @@ -0,0 +1,96 @@ +// +// AIStatus.swift +// InterlinedList +// + +import Foundation + +/// Daily AI budget. `/api/ai/status` reports `remaining`; the `suggest` and +/// `generate` responses omit it, so it falls back to the difference — both calls +/// decrement the same 50/day counter. +struct AIQuota: Codable, Equatable { + let usedToday: Int + let dailyLimit: Int + private let reportedRemaining: Int? + + var remaining: Int { reportedRemaining ?? max(0, dailyLimit - usedToday) } + var isExhausted: Bool { remaining <= 0 } + + enum CodingKeys: String, CodingKey { + case usedToday + case dailyLimit + case reportedRemaining = "remaining" + } + + init(usedToday: Int, dailyLimit: Int, remaining: Int? = nil) { + self.usedToday = usedToday + self.dailyLimit = dailyLimit + self.reportedRemaining = remaining + } +} + +/// `GET /api/ai/status`. Free to call — it is how a client learns whether to +/// surface AI at all. +struct AIStatus: Codable, Equatable { + /// The single gate for every AI affordance. AI is included with the + /// subscription; there are no user-supplied provider keys, so nothing else + /// on this payload may gate a control. + let subscriber: Bool + /// Display metadata only — never a precondition. + let providers: [String]? + /// Display metadata only — never a precondition. + let defaultModels: [String: String]? + let quota: AIQuota? + + /// Attribution line for a sheet footer, e.g. "Powered by Claude Sonnet 5". + var attribution: String? { + guard let provider = providers?.first, + let model = defaultModels?[provider] + else { return nil } + let name = model + .split(separator: "-") + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + return "Powered by \(name)" + } +} + +/// Token accounting returned alongside a suggestion. Informational. +struct AIUsage: Codable, Equatable { + let inputTokens: Int + let outputTokens: Int + let model: String +} + +/// Client-side mirror of the server's `lib/ai/limits.ts`. These gate the UI so a +/// request that the server would reject with `invalid_input` is never sent — and +/// never billed against the daily quota. The server remains authoritative. +enum AILimits { + /// Minimum composer content before the two series generators activate. + static let composerMinWords = 10 + + static let messageSeriesRange = 3...12 + static let messageSeriesDefaultCount = 5 + static let articleSeriesRange = 2...6 + static let articleSeriesDefaultCount = 4 + + /// Max words of free-text input per feature. + static func maxInputWords(_ feature: AIFeature) -> Int { + switch feature { + case .writingAssist: return 1500 + case .poweredTemplate: return 300 + case .messageSeries: return 500 + case .poweredDocument: return 500 + case .articleSeries: return 500 + } + } + + static func countWords(_ text: String) -> Int { + text.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).count + } + + /// Whether `text` is long enough for the composer's series generators. + static func meetsSeriesMinimum(_ text: String) -> Bool { + countWords(text) >= composerMinWords + } +} diff --git a/InterlinedList/Services/AIService.swift b/InterlinedList/Services/AIService.swift new file mode 100644 index 0000000..0476ae4 --- /dev/null +++ b/InterlinedList/Services/AIService.swift @@ -0,0 +1,132 @@ +// +// AIService.swift +// InterlinedList +// + +import Foundation +import os.log + +private let aiServiceLog = Logger(subsystem: "com.interlinedlist.app", category: "AIService") + +/// Owns AI availability and the shared daily quota for every AI affordance. +/// +/// AI is included with the subscription and runs on the app's own server-side +/// provider key, so availability is **one flag**: `subscriber`. There is no key +/// entry, no provider picker, and no BYO-key state to model. A free user sees no +/// AI control at all — never a disabled one, and never price or upgrade copy +/// (App Store Guideline 3.1.1). +@MainActor +final class AIService: ObservableObject { + static let shared = AIService() + + @Published private(set) var status: AIStatus? + /// Both `/suggest` and `/generate` decrement the same 50/day counter, so this + /// tracks whichever response spoke most recently. + @Published private(set) var quota: AIQuota? + /// Set when the server says it cannot serve AI at all right now + /// (`no_provider_configured`, i.e. a misconfigured or down provider) or the + /// entitlement is missing. Hides every affordance for the rest of the session. + @Published private(set) var isDisabledForSession = false + + private let api: APIClient + /// Several AI surfaces ask for the status as they appear; they share one + /// request rather than each firing their own. + private var statusLoad: Task? + + init(api: APIClient = .shared) { + self.api = api + } + + /// The single visibility gate. The locally-known `customerStatus` decides on + /// first render — it is the same value the server computes into + /// `status.subscriber` — and a loaded `/status` supersedes it. + func isAvailable(for user: User?) -> Bool { + if isDisabledForSession { return false } + if let status { return status.subscriber } + return user?.isSubscriber == true + } + + /// Attribution for a sheet footer. Display metadata, never a precondition. + var attribution: String? { status?.attribution } + + func loadStatusIfNeeded() async { + guard status == nil, !isDisabledForSession else { return } + await refreshStatus() + } + + func refreshStatus() async { + if let existing = statusLoad { + await existing.value + return + } + let load = Task { await performStatusRefresh() } + statusLoad = load + await load.value + statusLoad = nil + } + + private func performStatusRefresh() async { + do { + let fetched = try await api.aiStatus() + status = fetched + quota = fetched.quota + } catch let error as AIServiceError { + // A failed status read must not hide a control the user is entitled + // to; only an explicit "cannot serve AI" does that. + if case .unavailable = error { isDisabledForSession = true } + aiServiceLog.error("aiStatus failed: \(error.userMessage)") + } catch { + aiServiceLog.error("aiStatus failed: \(error.localizedDescription)") + } + } + + /// Preview an artifact without writing anything. Bills one quota unit. + func suggest( + feature: AIFeature, + input: String, + context: AIContext? = nil + ) async throws -> AIArtifact { + do { + let suggestion = try await api.aiSuggest(feature: feature, input: input, context: context) + if let updated = suggestion.quota { quota = updated } + return suggestion.artifact + } catch let error as AIServiceError { + record(error) + throw error + } + } + + /// Persist a confirmed artifact. Bills a second quota unit. + func generate( + feature: AIFeature, + artifact: AIArtifact, + channels: [String]? = nil, + scheduleImmediately: Bool? = nil, + crossPost: AIComposerCrossPost? = nil + ) async throws -> AICreated { + do { + let result = try await api.aiGenerate( + feature: feature, + artifact: artifact, + channels: channels, + scheduleImmediately: scheduleImmediately, + crossPost: crossPost + ) + if let updated = result.quota { quota = updated } + return result.created + } catch let error as AIServiceError { + record(error) + throw error + } + } + + /// Folds a failure back into the shared state: an entitlement or outage + /// failure retires the affordance, and a quota rejection zeroes the counter + /// so the remaining-count copy stops promising runs that will be refused. + private func record(_ error: AIServiceError) { + if error.hidesAffordance { isDisabledForSession = true } + if case .quotaExceeded = error, let current = quota { + quota = AIQuota(usedToday: current.dailyLimit, dailyLimit: current.dailyLimit, remaining: 0) + } + } +} diff --git a/InterlinedList/Services/APIClient+AI.swift b/InterlinedList/Services/APIClient+AI.swift new file mode 100644 index 0000000..4e4300a --- /dev/null +++ b/InterlinedList/Services/APIClient+AI.swift @@ -0,0 +1,162 @@ +// +// APIClient+AI.swift +// InterlinedList +// + +import Foundation +import os.log + +private let aiLog = Logger(subsystem: "com.interlinedlist.app", category: "APIClient+AI") + +/// A validated preview from `/api/ai/suggest`. Nothing has been written yet. +struct AISuggestion: Equatable { + let artifact: AIArtifact + let usage: AIUsage? + let quota: AIQuota? +} + +/// The result of persisting a confirmed artifact via `/api/ai/generate`. +struct AIGenerationResult: Equatable { + let created: AICreated + let quota: AIQuota? +} + +/// AI writing assistance (`/api/ai/{status,suggest,generate}`). +/// +/// This is the one feature that does **not** route through the shared transport +/// seam in `APIClientTransport.swift`, for two reasons: +/// +/// - It carries model-authored JSON that must survive a suggest → generate round +/// trip verbatim. The shared `decoder` converts from snake_case, which would +/// rewrite a Powered Template's DSL field keys and fail the server's +/// re-validation, so this file uses plain coders. +/// - `checkResponse` maps a failure onto `APIError`, dropping the machine `code` +/// and the `Retry-After` header. The AI error contract is built on both. +extension APIClient { + + func aiStatus() async throws -> AIStatus { + try await aiRequest("/api/ai/status", method: "GET", body: nil) + } + + func aiSuggest( + feature: AIFeature, + input: String, + context: AIContext? = nil, + maxOutputTokens: Int? = nil + ) async throws -> AISuggestion { + struct Body: Encodable { + let feature: AIFeature + let input: String + let context: AIContext? + let maxOutputTokens: Int? + } + struct Response: Decodable { + let artifact: AIArtifact + let usage: AIUsage? + let quota: AIQuota? + } + let body = Body(feature: feature, input: input, context: context, maxOutputTokens: maxOutputTokens) + let response: Response = try await aiRequest( + "/api/ai/suggest", + method: "POST", + body: try aiEncode(body) + ) + return AISuggestion(artifact: response.artifact, usage: response.usage, quota: response.quota) + } + + func aiGenerate( + feature: AIFeature, + artifact: AIArtifact, + channels: [String]? = nil, + scheduleImmediately: Bool? = nil, + crossPost: AIComposerCrossPost? = nil + ) async throws -> AIGenerationResult { + struct Body: Encodable { + let feature: AIFeature + let artifact: AIArtifact + let channels: [String]? + let scheduleImmediately: Bool? + let crossPost: AIComposerCrossPost? + } + struct Response: Decodable { + let created: AICreated + let quota: AIQuota? + } + let body = Body( + feature: feature, + artifact: artifact, + channels: channels, + scheduleImmediately: scheduleImmediately, + crossPost: crossPost + ) + let response: Response = try await aiRequest( + "/api/ai/generate", + method: "POST", + body: try aiEncode(body) + ) + return AIGenerationResult(created: response.created, quota: response.quota) + } + + // MARK: - Private + + /// Verbatim encoder: the AI wire format is camelCase, and a Powered + /// Template's DSL carries snake_case field keys that must not be rewritten. + private func aiEncode(_ body: B) throws -> Data { + try JSONEncoder().encode(body) + } + + private func aiRequest( + _ path: String, + method: String, + body: Data? + ) async throws -> Response { + guard let url = URL(string: baseURL + path) else { throw AIServiceError.transport } + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + if let token = bearerToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + request.httpBody = body + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + aiLog.error("\(method) \(path) transport failure: \(error.localizedDescription)") + throw AIServiceError.transport + } + + if let http = response as? HTTPURLResponse, http.statusCode >= 400 { + let failure = Self.aiFailure(status: http.statusCode, data: data, headers: http) + aiLog.error("\(method) \(path) → \(http.statusCode)") + throw failure + } + + do { + return try JSONDecoder().decode(Response.self, from: data) + } catch { + aiLog.error("\(method) \(path) decode failed: \(error.localizedDescription)") + throw AIServiceError.invalidOutput + } + } + + private static func aiFailure(status: Int, data: Data, headers: HTTPURLResponse) -> AIServiceError { + struct ErrorBody: Decodable { + let error: String? + let code: String? + } + let parsed = try? JSONDecoder().decode(ErrorBody.self, from: data) + let retryAfter = headers.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init) + return AIServiceError( + status: status, + code: parsed?.code, + message: parsed?.error, + retryAfter: retryAfter + ) + } +} diff --git a/InterlinedList/Views/AIPoweredDocumentSheet.swift b/InterlinedList/Views/AIPoweredDocumentSheet.swift new file mode 100644 index 0000000..df1d7b3 --- /dev/null +++ b/InterlinedList/Views/AIPoweredDocumentSheet.swift @@ -0,0 +1,360 @@ +// +// AIPoweredDocumentSheet.swift +// InterlinedList +// + +import SwiftUI + +/// Powered Document — four ways to draft a document: from a topic, from one of +/// your lists, from an existing document, or from a web page. +/// +/// Derived modes send **ids only**; the server re-fetches and authorizes each +/// source itself (and guards the URL fetch), so nothing here has to ship the +/// source content. The document is created at the root, which is why the entry +/// point lives on the Documents root rather than inside a folder. +struct AIPoweredDocumentSheet: View { + let onCreate: (Document?) -> Void + + @EnvironmentObject private var authState: AuthState + @EnvironmentObject private var store: AppDataStore + @ObservedObject private var ai = AIService.shared + @Environment(\.dismiss) private var dismiss + + @State private var mode: AIDocumentMode = .article + @State private var instruction = "" + @State private var selectedListId: String? + @State private var selectedDocumentId: String? + @State private var url = "" + @State private var lists: [UserList] = [] + @State private var artifact: AIDocumentArtifact? + @State private var isWorking = false + @State private var errorMessage: String? + @State private var canRetry = false + + private var trimmedInstruction: String { + instruction.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var trimmedURL: String { + url.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canGenerate: Bool { + guard AILimits.countWords(trimmedInstruction) <= AILimits.maxInputWords(.poweredDocument) else { + return false + } + switch mode { + case .article: return !trimmedInstruction.isEmpty + case .fromList: return selectedListId != nil + case .fromArticle: return selectedDocumentId != nil + case .researchUrl: return isValidHTTPURL(trimmedURL) + } + } + + private func isValidHTTPURL(_ candidate: String) -> Bool { + guard let parsed = URL(string: candidate), let scheme = parsed.scheme?.lowercased() else { + return false + } + return (scheme == "http" || scheme == "https") && (parsed.host?.isEmpty == false) + } + + /// Mirrors the web's per-mode default instruction. + private var effectiveInput: String { + if !trimmedInstruction.isEmpty { return trimmedInstruction } + switch mode { + case .article: return trimmedInstruction + case .researchUrl: return "Write a document about this page." + case .fromList, .fromArticle: return "Write a coherent document from this source." + } + } + + private var context: AIContext { + switch mode { + case .article: + return .poweredDocument(mode: .article) + case .fromList: + return .poweredDocument(mode: .fromList, listId: selectedListId) + case .fromArticle: + return .poweredDocument(mode: .fromArticle, documentId: selectedDocumentId) + case .researchUrl: + return .poweredDocument(mode: .researchUrl, url: trimmedURL) + } + } + + var body: some View { + NavigationStack { + Group { + if let artifact { + previewForm(artifact) + } else { + inputForm + } + } + .navigationTitle("Powered Document") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .task { + await ai.loadStatusIfNeeded() + await loadLists() + } + } + } + + // MARK: - Input + + private var inputForm: some View { + Form { + Section { + Picker("Mode", selection: $mode) { + ForEach(AIDocumentMode.allCases) { option in + Label(option.label, systemImage: option.systemImage).tag(option) + } + } + .accessibilityLabel("Document source") + sourcePicker + } header: { + Text("Source") + } footer: { + Text(sourceFooter) + .font(.ilMono()) + } + + Section { + TextField(instructionPlaceholder, text: $instruction, axis: .vertical) + .lineLimit(3...6) + .accessibilityLabel("Instruction") + } header: { + Text(mode == .article ? "What Should It Cover?" : "Instruction (Optional)") + } footer: { + if AILimits.countWords(trimmedInstruction) > AILimits.maxInputWords(.poweredDocument) { + Text("Too long — keep it under \(AILimits.maxInputWords(.poweredDocument)) words.") + .font(.ilMono()) + .foregroundStyle(.red) + } + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.ilBody(15)) + .foregroundStyle(.red) + if canRetry { + Button("Try Again") { Task { await generate() } } + .disabled(isWorking) + } + } + } + + Section { + Button { + Task { await generate() } + } label: { + HStack { + if isWorking { + ProgressView().frame(width: 20, height: 20) + } + Text(isWorking ? "Drafting…" : "Draft Document") + .frame(maxWidth: .infinity) + } + } + .disabled(isWorking || !canGenerate) + } footer: { + quotaFooter + } + } + } + + @ViewBuilder + private var sourcePicker: some View { + switch mode { + case .article: + EmptyView() + case .fromList: + Picker("List", selection: $selectedListId) { + Text("Select a list").tag(String?.none) + ForEach(lists) { list in + Text(list.name).tag(String?.some(list.id)) + } + } + .accessibilityLabel("Source list") + case .fromArticle: + Picker("Document", selection: $selectedDocumentId) { + Text("Select a document").tag(String?.none) + ForEach(store.documents) { document in + Text(document.title).tag(String?.some(document.id)) + } + } + .accessibilityLabel("Source document") + case .researchUrl: + TextField("https://…", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .accessibilityLabel("Source URL") + } + } + + private var sourceFooter: String { + switch mode { + case .article: return "Drafts a document from your description alone." + case .fromList: return "Narrates one of your lists. The server reads the rows — no rows are invented." + case .fromArticle: return "Rewrites one of your documents: summarize, expand, or restructure." + case .researchUrl: return "Reads a public web page and writes about it, citing the source." + } + } + + private var instructionPlaceholder: String { + mode == .article + ? "Draft a runbook for our deploy process…" + : "Summarize, expand, restructure…" + } + + // MARK: - Preview + + private func previewForm(_ artifact: AIDocumentArtifact) -> some View { + Form { + Section { + Text(artifact.title) + .font(.ilBody(15)) + .fontWeight(.medium) + } header: { + Text("Title") + } + + if let outline = artifact.outline, !outline.isEmpty { + Section { + ForEach(Array(outline.enumerated()), id: \.offset) { _, entry in + Text(entry) + .font(.ilMono()) + } + } header: { + Text("Outline") + } + } + + Section { + Text(artifact.markdown) + .font(.ilBody(14)) + .textSelection(.enabled) + } header: { + Text("Draft") + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.ilBody(15)) + .foregroundStyle(.red) + } + } + + Section { + Button { + Task { await create(artifact) } + } label: { + HStack { + if isWorking { + ProgressView().frame(width: 20, height: 20) + } + Text("Create Document") + .frame(maxWidth: .infinity) + } + } + .disabled(isWorking) + Button("Start Over") { + self.artifact = nil + errorMessage = nil + } + .disabled(isWorking) + } footer: { + VStack(alignment: .leading, spacing: 2) { + Text("Created as a private document you can edit afterwards.") + quotaFooter + } + } + } + } + + @ViewBuilder + private var quotaFooter: some View { + VStack(alignment: .leading, spacing: 2) { + if let quota = ai.quota { + Text("\(quota.remaining) of \(quota.dailyLimit) AI runs left today.") + } + if let attribution = ai.attribution { + Text(attribution) + } + } + .font(.ilMono()) + } + + // MARK: - Actions + + private func loadLists() async { + guard lists.isEmpty else { return } + lists = store.userLists.isEmpty ? ((try? await APIClient.shared.lists()) ?? []) : store.userLists + } + + private func generate() async { + guard canGenerate, !isWorking else { return } + errorMessage = nil + canRetry = false + isWorking = true + defer { isWorking = false } + do { + let suggestion = try await ai.suggest( + feature: .poweredDocument, + input: effectiveInput, + context: context + ) + guard case .document(let document) = suggestion else { + errorMessage = AIServiceError.invalidOutput.userMessage + canRetry = true + return + } + artifact = document + } catch AIServiceError.unauthorized { + authState.handleUnauthorized() + } catch let error as AIServiceError { + errorMessage = error.userMessage + canRetry = error.isRetryable + } catch { + errorMessage = AIServiceError.transport.userMessage + canRetry = true + } + } + + private func create(_ document: AIDocumentArtifact) async { + guard !isWorking else { return } + errorMessage = nil + isWorking = true + defer { isWorking = false } + do { + let created = try await ai.generate(feature: .poweredDocument, artifact: .document(document)) + // `/generate` returns only the id; resolve the real document so the + // caller can open it. `nil` means "created, but refresh to see it". + var resolved: Document? + if let id = created.documentId { + resolved = (try? await APIClient.shared.documents())?.first { $0.id == id } + } + onCreate(resolved) + dismiss() + } catch AIServiceError.unauthorized { + authState.handleUnauthorized() + } catch let error as AIServiceError { + errorMessage = error.userMessage + } catch { + errorMessage = AIServiceError.transport.userMessage + } + } +} + +#Preview { + AIPoweredDocumentSheet(onCreate: { _ in }) + .environmentObject(AuthState()) + .environmentObject(AppDataStore()) +} diff --git a/InterlinedList/Views/AIPoweredTemplateView.swift b/InterlinedList/Views/AIPoweredTemplateView.swift new file mode 100644 index 0000000..c85cae5 --- /dev/null +++ b/InterlinedList/Views/AIPoweredTemplateView.swift @@ -0,0 +1,320 @@ +// +// AIPoweredTemplateView.swift +// InterlinedList +// + +import SwiftUI + +/// Powered Templates: describe a list in plain language and the AI drafts its +/// schema and starter rows. Runs the same suggest → preview → confirm → +/// generate flow as the rest of the AI surface. +/// +/// The generated list is created server-side from the artifact verbatim, so +/// column types the iOS schema editor can't express (select, multiselect, and +/// their options) survive intact; the schema is editable afterwards in the list. +struct AIPoweredTemplateView: View { + let onCreate: (UserList?) -> Void + + @EnvironmentObject private var authState: AuthState + @ObservedObject private var ai = AIService.shared + @Environment(\.dismiss) private var dismiss + + @State private var selectedTemplateId: String? + @State private var prompt = "" + @State private var artifact: AIListArtifact? + @State private var isWorking = false + @State private var errorMessage: String? + @State private var canRetry = false + + private var selectedTemplate: AIPoweredTemplate? { + AIPoweredTemplate.gallery.first { $0.id == selectedTemplateId } + } + + private var trimmedPrompt: String { + prompt.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var promptWordCount: Int { AILimits.countWords(trimmedPrompt) } + + private var isPromptValid: Bool { + !trimmedPrompt.isEmpty && promptWordCount <= AILimits.maxInputWords(.poweredTemplate) + } + + var body: some View { + Group { + if let artifact { + previewForm(artifact) + } else { + promptForm + } + } + .task { await ai.loadStatusIfNeeded() } + } + + // MARK: - Prompt + + private var promptForm: some View { + Form { + Section { + ForEach(AIPoweredTemplate.gallery) { template in + Button { + selectedTemplateId = (selectedTemplateId == template.id) ? nil : template.id + } label: { + templateRow(template) + } + .buttonStyle(.plain) + .accessibilityLabel("\(template.name) template\(selectedTemplateId == template.id ? ", selected" : "")") + } + } header: { + Text("Start From") + } footer: { + Text("Optional — a template steers the shape. Skip it and the description alone decides.") + .font(.ilMono()) + } + + Section { + TextField( + selectedTemplate?.promptPlaceholder ?? "e.g. Track conference talks: event, date, status, slides URL", + text: $prompt, + axis: .vertical + ) + .lineLimit(3...6) + .accessibilityLabel("Describe the list to build") + } header: { + Text("Describe Your List") + } footer: { + if promptWordCount > AILimits.maxInputWords(.poweredTemplate) { + Text("Too long — keep it under \(AILimits.maxInputWords(.poweredTemplate)) words.") + .font(.ilMono()) + .foregroundStyle(.red) + } else { + Text("Describe what you want to track and the columns are tailored to it.") + .font(.ilMono()) + } + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.ilBody(15)) + .foregroundStyle(.red) + if canRetry { + Button("Try Again") { Task { await generate() } } + .disabled(isWorking) + } + } + } + + Section { + Button { + Task { await generate() } + } label: { + HStack { + if isWorking { + ProgressView().frame(width: 20, height: 20) + } + Text(isWorking ? "Drafting schema…" : "Draft Schema") + .frame(maxWidth: .infinity) + } + } + .disabled(isWorking || !isPromptValid) + } footer: { + quotaFooter + } + } + } + + @ViewBuilder + private func templateRow(_ template: AIPoweredTemplate) -> some View { + HStack(alignment: .top, spacing: 12) { + Text(template.icon) + .font(.system(size: 26)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(template.name) + .font(.ilBody(15)) + .fontWeight(.medium) + .foregroundStyle(.primary) + Text(template.summary) + .font(.ilMono()) + .foregroundStyle(.secondary) + Text(template.previewChips.joined(separator: " · ")) + .font(.ilMono()) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + if selectedTemplateId == template.id { + Image(systemName: "checkmark") + .foregroundStyle(Color(ILColor.primary)) + } + } + .contentShape(Rectangle()) + } + + // MARK: - Preview + + private func previewForm(_ artifact: AIListArtifact) -> some View { + Form { + Section { + Text(artifact.title) + .font(.ilBody(15)) + .fontWeight(.medium) + if let description = artifact.description, !description.isEmpty { + Text(description) + .font(.ilMono()) + .foregroundStyle(.secondary) + } + } header: { + Text("List") + } + + Section { + ForEach(artifact.fields) { field in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(field.label) + .font(.ilBody(15)) + if !field.options.isEmpty { + Text(field.options.joined(separator: " · ")) + .font(.ilMono()) + .foregroundStyle(.secondary) + } + } + Spacer() + Text(field.type) + .font(.ilMono()) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(field.label), \(field.type)\(field.isRequired ? ", required" : "")") + } + } header: { + Text("Columns") + } + + if let rows = artifact.rows, !rows.isEmpty { + Section { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + VStack(alignment: .leading, spacing: 2) { + ForEach(artifact.rowSummary(row), id: \.label) { pair in + Text("\(pair.label): \(pair.value)") + .font(.ilMono()) + } + } + } + } header: { + Text("Starter Rows") + } + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.ilBody(15)) + .foregroundStyle(.red) + } + } + + Section { + Button { + Task { await create(artifact) } + } label: { + HStack { + if isWorking { + ProgressView().frame(width: 20, height: 20) + } + Text("Create List") + .frame(maxWidth: .infinity) + } + } + .disabled(isWorking) + Button("Start Over") { + self.artifact = nil + errorMessage = nil + } + .disabled(isWorking) + } footer: { + VStack(alignment: .leading, spacing: 2) { + Text("Created as a private list. You can edit the columns and sharing afterwards.") + quotaFooter + } + } + } + } + + @ViewBuilder + private var quotaFooter: some View { + VStack(alignment: .leading, spacing: 2) { + if let quota = ai.quota { + Text("\(quota.remaining) of \(quota.dailyLimit) AI runs left today.") + } + if let attribution = ai.attribution { + Text(attribution) + } + } + .font(.ilMono()) + } + + // MARK: - Actions + + private func generate() async { + guard isPromptValid, !isWorking else { return } + errorMessage = nil + canRetry = false + isWorking = true + defer { isWorking = false } + do { + let suggestion = try await ai.suggest( + feature: .poweredTemplate, + input: trimmedPrompt, + context: .poweredTemplate(templateKey: selectedTemplateId) + ) + guard case .list(let list) = suggestion else { + errorMessage = AIServiceError.invalidOutput.userMessage + canRetry = true + return + } + artifact = list + } catch AIServiceError.unauthorized { + authState.handleUnauthorized() + } catch let error as AIServiceError { + errorMessage = error.userMessage + canRetry = error.isRetryable + } catch { + errorMessage = AIServiceError.transport.userMessage + canRetry = true + } + } + + private func create(_ list: AIListArtifact) async { + guard !isWorking else { return } + errorMessage = nil + isWorking = true + defer { isWorking = false } + do { + let created = try await ai.generate(feature: .poweredTemplate, artifact: .list(list)) + // `/generate` returns only the id, so resolve the real list for the + // caller. A failed lookup still counts as created — `nil` tells the + // caller to refresh rather than that nothing happened. + var resolved: UserList? + if let id = created.listId { + resolved = (try? await APIClient.shared.lists())?.first { $0.id == id } + } + onCreate(resolved) + dismiss() + } catch AIServiceError.unauthorized { + authState.handleUnauthorized() + } catch let error as AIServiceError { + errorMessage = error.userMessage + } catch { + errorMessage = AIServiceError.transport.userMessage + } + } +} + +#Preview { + NavigationStack { + AIPoweredTemplateView(onCreate: { _ in }) + } + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/AISeriesGeneratorSection.swift b/InterlinedList/Views/AISeriesGeneratorSection.swift new file mode 100644 index 0000000..238853d --- /dev/null +++ b/InterlinedList/Views/AISeriesGeneratorSection.swift @@ -0,0 +1,75 @@ +// +// AISeriesGeneratorSection.swift +// InterlinedList +// + +import SwiftUI + +/// The composer's two series generators. Both stay inert until the draft holds +/// at least `AILimits.composerMinWords` words — the server rejects anything +/// shorter, and a rejected call still costs a quota unit. +/// +/// The caller hides this section entirely for anyone without the AI entitlement. +struct AISeriesGeneratorSection: View { + let content: String + /// The composer's live cross-post selection, forwarded to the sheet. + let crossPost: AIComposerCrossPost + let maxMessageLength: Int + let onCreated: (AICreated) -> Void + + @State private var activeFeature: AIFeature? + + private var wordCount: Int { + AILimits.countWords(content.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private var isReady: Bool { wordCount >= AILimits.composerMinWords } + + var body: some View { + Section { + Button { + activeFeature = .messageSeries + } label: { + Label("Message Series", systemImage: "text.bubble") + } + .disabled(!isReady) + .accessibilityLabel("Generate a message series") + + Button { + activeFeature = .articleSeries + } label: { + Label("Article Series", systemImage: "doc.text.below.ecg") + } + .disabled(!isReady) + .accessibilityLabel("Generate an article series") + } header: { + Text("Generate a Series") + } footer: { + Text(isReady + ? "Drafts a set of posts or documents from what you've written. You'll review before anything is created." + : "Write at least \(AILimits.composerMinWords) words to generate a series (\(wordCount) so far).") + .font(.ilMono()) + } + .sheet(item: $activeFeature) { feature in + AISeriesSheet( + feature: feature, + input: content.trimmingCharacters(in: .whitespacesAndNewlines), + crossPost: crossPost, + fallbackCharLimit: maxMessageLength, + onCreated: onCreated + ) + } + } +} + +#Preview { + Form { + AISeriesGeneratorSection( + content: "A launch week campaign about the AI writing features shipping this month.", + crossPost: AIComposerCrossPost(crossPostToBluesky: true), + maxMessageLength: 666, + onCreated: { _ in } + ) + } + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/AISeriesSheet.swift b/InterlinedList/Views/AISeriesSheet.swift new file mode 100644 index 0000000..b772052 --- /dev/null +++ b/InterlinedList/Views/AISeriesSheet.swift @@ -0,0 +1,327 @@ +// +// AISeriesSheet.swift +// InterlinedList +// + +import SwiftUI +import Combine + +/// Message Series / Article Series: the full suggest → preview → confirm → +/// generate flow. Both steps bill against the shared 50/day AI quota, so the +/// preview is a real decision point, not a formality. +struct AISeriesSheet: View { + let feature: AIFeature + /// The composer draft the series is generated from. + let input: String + /// The composer's live cross-post selection. Message Series reads it rather + /// than offering a second picker: it sizes the generated posts, and — when + /// scheduling directly — decides which accounts they cross-post to. + let crossPost: AIComposerCrossPost + /// The user's own message limit, used when nothing is cross-posted. + let fallbackCharLimit: Int + let onCreated: (AICreated) -> Void + + @EnvironmentObject private var authState: AuthState + @ObservedObject private var ai = AIService.shared + @Environment(\.dismiss) private var dismiss + + @State private var artifact: AIArtifact? + @State private var created: AICreated? + @State private var isWorking = false + @State private var errorMessage: String? + @State private var canRetry = false + @State private var scheduleImmediately = false + @State private var elapsed: TimeInterval = 0 + + private let ticker = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + + private var channelLabels: [String] { crossPost.channelLabels } + + private var charLimit: Int { + AICrossPostLimits.minCharLimit(labels: channelLabels, fallback: fallbackCharLimit) + } + + private var isMessageSeries: Bool { feature == .messageSeries } + + var body: some View { + NavigationStack { + Form { + if let created { + createdSection(created) + } else if let artifact { + previewSections(artifact) + } else { + loadingSection + } + } + .navigationTitle(navTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(created == nil ? "Cancel" : "Done") { dismiss() } + } + if artifact != nil && created == nil { + ToolbarItem(placement: .confirmationAction) { + Button(confirmLabel) { Task { await confirm() } } + .disabled(isWorking) + } + } + } + .task { await generatePreview() } + .onReceive(ticker) { _ in + guard artifact == nil, created == nil, errorMessage == nil else { return } + elapsed += 0.5 + } + } + } + + private var navTitle: String { + isMessageSeries ? "Message Series" : "Article Series" + } + + private var confirmLabel: String { + (isMessageSeries && scheduleImmediately) ? "Schedule" : "Create" + } + + // MARK: - Loading + + @ViewBuilder + private var loadingSection: some View { + if let errorMessage { + Section { + Text(errorMessage) + .font(.ilBody(15)) + .foregroundStyle(.red) + if canRetry { + Button("Try Again") { + Task { await generatePreview(isRetry: true) } + } + .disabled(isWorking) + } + } + } else { + Section { + HStack(spacing: 12) { + ProgressView() + VStack(alignment: .leading, spacing: 2) { + Text(currentCaption) + .font(.ilBody(15)) + Text("\(Int(elapsed))s elapsed") + .font(.ilMono()) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(currentCaption). \(Int(elapsed)) seconds elapsed.") + } header: { + Text(AISeriesLoading.title(for: feature)) + } + } + } + + private var currentCaption: String { + let captions = AISeriesLoading.captions(for: feature) + let index = AISeriesLoading.stepIndex(elapsed: elapsed, captionCount: captions.count) + return captions[index] + } + + // MARK: - Preview + + @ViewBuilder + private func previewSections(_ artifact: AIArtifact) -> some View { + switch artifact { + case .messageSeries(let series): + Section { + ForEach(Array(series.items.enumerated()), id: \.offset) { index, item in + VStack(alignment: .leading, spacing: 4) { + Text("\(index + 1)") + .font(.ilMono()) + .foregroundStyle(.secondary) + Text(item.content) + .font(.ilBody(15)) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Message \(index + 1): \(item.content)") + } + } header: { + Text(series.listTitle) + } footer: { + Text(sizingNote) + .font(.ilMono()) + } + Section { + Toggle("Schedule immediately", isOn: $scheduleImmediately) + .accessibilityLabel("Schedule these messages immediately instead of creating a list") + } footer: { + Text(scheduleImmediately + ? "Posts directly — the first in about 30–45 minutes, then 5–10 minutes apart." + : "Creates a list you can review and schedule yourself.") + .font(.ilMono()) + } + case .docSeries(let series): + Section { + ForEach(Array(series.documents.enumerated()), id: \.offset) { index, document in + VStack(alignment: .leading, spacing: 4) { + Text(document.title) + .font(.ilBody(15)) + .fontWeight(.medium) + if let outline = document.outline, !outline.isEmpty { + Text(outline.joined(separator: " · ")) + .font(.ilMono()) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Document \(index + 1): \(document.title)") + } + } header: { + Text(series.folderTitle) + } footer: { + Text("Creates a folder of \(series.documents.count) documents you can edit.") + .font(.ilMono()) + } + default: + Section { + Text("That result can't be used as a series.") + .font(.ilBody(15)) + .foregroundStyle(.secondary) + } + } + if let errorMessage { + Section { + Text(errorMessage) + .font(.ilBody(15)) + .foregroundStyle(.red) + } + } + quotaFooterSection + } + + private var sizingNote: String { + if channelLabels.isEmpty { + return "In-app only — no cross-post selected. Sized to fit \(charLimit) characters." + } + return "Cross-posts to \(channelLabels.joined(separator: ", ")) — sized to fit \(charLimit) characters." + } + + @ViewBuilder + private var quotaFooterSection: some View { + if let quota = ai.quota { + Section { + VStack(alignment: .leading, spacing: 2) { + Text("\(quota.remaining) of \(quota.dailyLimit) AI runs left today. Creating uses one more.") + if let attribution = ai.attribution { + Text(attribution) + } + } + .font(.ilMono()) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Created + + @ViewBuilder + private func createdSection(_ created: AICreated) -> some View { + Section { + Label(Self.confirmation(for: created), systemImage: "checkmark.circle.fill") + .font(.ilBody(15)) + .foregroundStyle(Color(ILColor.primary)) + } footer: { + if created.scheduledMessageIds != nil { + Text("Review or edit them under Scheduled.") + .font(.ilMono()) + } + } + } + + /// Mirrors the web's post-generate confirmation copy. + static func confirmation(for created: AICreated) -> String { + if let ids = created.scheduledMessageIds { + let noun = ids.count == 1 ? "message" : "messages" + guard let first = created.firstScheduledAt, let date = parseTimestamp(first) else { + return "Scheduled \(ids.count) \(noun)." + } + let time = date.formatted(date: .omitted, time: .shortened) + return "Scheduled \(ids.count) \(noun) — first around \(time)." + } + if created.listId != nil { + return "Message series list created." + } + if created.folderId != nil { + let count = created.documentIds?.count ?? 0 + return "Article series created (\(count) documents)." + } + return "Created." + } + + private static func parseTimestamp(_ iso: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) + } + + // MARK: - Actions + + private func generatePreview(isRetry: Bool = false) async { + guard artifact == nil, !isWorking else { return } + errorMessage = nil + canRetry = false + if isRetry { elapsed = 0 } + isWorking = true + defer { isWorking = false } + do { + artifact = try await ai.suggest( + feature: feature, + input: input, + context: isMessageSeries ? .messageSeries(channels: channelLabels) : nil + ) + } catch AIServiceError.unauthorized { + authState.handleUnauthorized() + dismiss() + } catch let error as AIServiceError { + errorMessage = error.userMessage + canRetry = error.isRetryable + } catch { + errorMessage = AIServiceError.transport.userMessage + canRetry = true + } + } + + private func confirm() async { + guard let artifact, !isWorking else { return } + errorMessage = nil + isWorking = true + defer { isWorking = false } + do { + let result = try await ai.generate( + feature: feature, + artifact: artifact, + channels: isMessageSeries ? channelLabels : nil, + scheduleImmediately: isMessageSeries ? scheduleImmediately : nil, + crossPost: isMessageSeries ? crossPost : nil + ) + created = result + onCreated(result) + } catch AIServiceError.unauthorized { + authState.handleUnauthorized() + dismiss() + } catch let error as AIServiceError { + errorMessage = error.userMessage + } catch { + errorMessage = AIServiceError.transport.userMessage + } + } +} + +#Preview { + AISeriesSheet( + feature: .messageSeries, + input: "A launch week campaign about the new AI writing features shipping this month.", + crossPost: AIComposerCrossPost(crossPostToBluesky: true), + fallbackCharLimit: 666, + onCreated: { _ in } + ) + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/AISuggestionSheet.swift b/InterlinedList/Views/AISuggestionSheet.swift new file mode 100644 index 0000000..9f2b1c8 --- /dev/null +++ b/InterlinedList/Views/AISuggestionSheet.swift @@ -0,0 +1,116 @@ +// +// AISuggestionSheet.swift +// InterlinedList +// + +import SwiftUI + +/// Renders one Writing Assistant suggestion and hands it back to the composer. +/// Presentation only — the caller decides what "apply" means for its own draft. +struct AISuggestionSheet: View { + let artifact: AIArtifact + let attribution: String? + let onReplaceDraft: (String) -> Void + let onAddTags: ([String]) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + content + } footer: { + if let attribution { + Text(attribution) + .font(.ilMono()) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(applyLabel) { apply() } + .accessibilityLabel(applyLabel) + } + } + } + .presentationDetents([.medium, .large]) + } + + private var title: String { + switch artifact { + case .thread(let parts): return "Thread · \(parts.count) parts" + case .tags: return "Suggested Tags" + default: return "Suggestion" + } + } + + private var applyLabel: String { + switch artifact { + case .thread: return "Use in Draft" + case .tags: return "Add Tags" + default: return "Replace Draft" + } + } + + @ViewBuilder + private var content: some View { + switch artifact { + case .message(let text): + Text(text) + .font(.ilBody(15)) + .textSelection(.enabled) + case .thread(let parts): + ForEach(Array(parts.enumerated()), id: \.offset) { index, part in + VStack(alignment: .leading, spacing: 4) { + Text("Part \(index + 1)") + .font(.ilMono()) + .foregroundStyle(.secondary) + Text(part) + .font(.ilBody(15)) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Part \(index + 1): \(part)") + } + case .tags(let tags): + ForEach(tags, id: \.self) { tag in + Label("#\(tag)", systemImage: "number") + .font(.ilBody(15)) + } + case .document, .messageSeries, .docSeries, .list: + // The Writing Assistant only ever produces message / thread / tags. + Text("This suggestion can't be applied to the draft.") + .font(.ilBody(15)) + .foregroundStyle(.secondary) + } + } + + private func apply() { + switch artifact { + case .message(let text): + onReplaceDraft(text) + case .thread(let parts): + onReplaceDraft(parts.joined(separator: "\n\n")) + case .tags(let tags): + onAddTags(tags) + case .document, .messageSeries, .docSeries, .list: + break + } + dismiss() + } +} + +#Preview { + AISuggestionSheet( + artifact: .thread(["Opening line of the thread.", "The follow-up that carries the argument."]), + attribution: "Powered by Claude Sonnet 5", + onReplaceDraft: { _ in }, + onAddTags: { _ in } + ) +} diff --git a/InterlinedList/Views/AIWritingAssistantButton.swift b/InterlinedList/Views/AIWritingAssistantButton.swift new file mode 100644 index 0000000..5e20035 --- /dev/null +++ b/InterlinedList/Views/AIWritingAssistantButton.swift @@ -0,0 +1,133 @@ +// +// AIWritingAssistantButton.swift +// InterlinedList +// + +import SwiftUI + +/// The composer's pen menu: Rewrite / Tighten / Expand / Fix grammar / Split +/// into thread / Suggest tags. Runs `/api/ai/suggest` and presents the result. +/// +/// The caller is responsible for hiding this entirely when +/// `AIService.isAvailable(for:)` is false — a free user must see no AI control at +/// all, not a disabled one (App Store Guideline 3.1.1). +struct AIWritingAssistantButton: View { + @Binding var content: String + /// Applies suggested tags. The composer routes them to its tags field; a + /// reply (which has no tags field) appends them to the draft as hashtags. + let onAddTags: ([String]) -> Void + + @EnvironmentObject private var authState: AuthState + @ObservedObject private var ai = AIService.shared + + @State private var isWorking = false + @State private var suggestion: AISuggestionItem? + @State private var errorMessage: String? + + /// Below this the assistant has nothing to work with. Mirrors the web + /// composer's guard; the server would reject an empty input outright. + private static let minimumWords = 2 + + private var canAssist: Bool { + AILimits.countWords(content) >= Self.minimumWords + } + + var body: some View { + Menu { + ForEach(AIWritingAction.allCases) { action in + Button { + Task { await run(action) } + } label: { + Label(action.label, systemImage: action.systemImage) + } + } + } label: { + if isWorking { + ProgressView() + .frame(width: 20, height: 20) + } else { + Image(systemName: "pencil.and.sparkles") + .font(.ilBody()) + .foregroundStyle(canAssist ? Color(ILColor.primary) : Color.secondary) + } + } + .buttonStyle(.borderless) + .disabled(isWorking || !canAssist) + .accessibilityLabel("AI writing assistant") + .sheet(item: $suggestion) { item in + AISuggestionSheet( + artifact: item.artifact, + attribution: ai.attribution, + onReplaceDraft: { content = $0 }, + onAddTags: onAddTags + ) + } + .alert("Assistant Unavailable", isPresented: errorBinding) { + Button("OK", role: .cancel) { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + .task { await ai.loadStatusIfNeeded() } + } + + private var errorBinding: Binding { + Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + } + + private func run(_ action: AIWritingAction) async { + let draft = content.trimmingCharacters(in: .whitespacesAndNewlines) + let words = AILimits.countWords(draft) + guard words >= Self.minimumWords else { + errorMessage = "Write a little more before using the assistant." + return + } + // Refuse locally rather than spending a quota unit on a request the + // server will reject for size. + let maxWords = AILimits.maxInputWords(.writingAssist) + guard words <= maxWords else { + errorMessage = "The draft is too long for the assistant (limit \(maxWords) words)." + return + } + isWorking = true + defer { isWorking = false } + do { + let artifact = try await ai.suggest( + feature: .writingAssist, + input: draft, + context: .writingAssist(action) + ) + suggestion = AISuggestionItem(artifact: artifact) + } catch AIServiceError.unauthorized { + authState.handleUnauthorized() + } catch let error as AIServiceError { + errorMessage = error.userMessage + } catch { + errorMessage = AIServiceError.transport.userMessage + } + } +} + +/// `sheet(item:)` needs identity and an artifact is a plain value, so each +/// presentation carries its own. +private struct AISuggestionItem: Identifiable { + let id = UUID() + let artifact: AIArtifact +} + +#Preview { + Form { + HStack { + Text("120 characters remaining") + .font(.ilMono()) + .foregroundStyle(.secondary) + AIWritingAssistantButton( + content: .constant("A short draft the assistant can work with."), + onAddTags: { _ in } + ) + } + } + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index 7495385..b519b11 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -15,6 +15,7 @@ private let defaultMaxMessageLength = 666 struct ComposeView: View { @EnvironmentObject var authState: AuthState @EnvironmentObject var store: AppDataStore + @ObservedObject private var ai = AIService.shared @Environment(\.dismiss) private var dismiss /// When set, this view posts a reply to the given message. var replyTo: Message? = nil @@ -66,6 +67,46 @@ struct ComposeView: View { authState.user?.isSubscriber == true } + /// AI writing assistance is a subscriber entitlement served from the app's + /// own provider key. One flag decides visibility; a free user sees no control + /// at all, and no price or upgrade copy ever appears in-app. + private var canUseAI: Bool { + ai.isAvailable(for: authState.user) + } + + /// The composer's live cross-post selection in the shape `/api/ai/generate` + /// expects. Message Series reads this rather than offering its own picker: + /// it sizes the generated posts to the tightest selected service and, when + /// scheduling directly, cross-posts to exactly these accounts. + private var aiCrossPostSelection: AIComposerCrossPost { + guard canUseSubscriberFeatures, !isReply else { return AIComposerCrossPost() } + let linkedInOn = crossPostLinkedIn && hasLinkedIn + return AIComposerCrossPost( + crossPostToBluesky: crossPostBluesky && hasBluesky ? true : nil, + selectedMastodonIds: selectedMastodonIds.isEmpty ? nil : Array(selectedMastodonIds), + crossPostToTwitter: crossPostTwitter && hasTwitter ? true : nil, + crossPostToLinkedIn: linkedInOn ? true : nil, + selectedLinkedInTargets: linkedInOn ? resolvedLinkedInTargets : nil, + linkedInLinkAsFirstComment: linkedInOn && linkedInLinkAsFirstComment ? true : nil + ) + } + + /// Merges suggested tags into the tags field, case-insensitively de-duped + /// against what's already there. Only reachable from the full composer — the + /// assistant lives in `advancedToolbar`, which replies don't render. + private func applySuggestedTags(_ suggested: [String]) { + guard !suggested.isEmpty else { return } + let existing = tags + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + var merged = existing + for tag in suggested where !merged.contains(where: { $0.caseInsensitiveCompare(tag) == .orderedSame }) { + merged.append(tag) + } + tags = merged.joined(separator: ", ") + } + /// Free users with unverified email cannot post (matches site behavior). /// User is missing while view is initializing; treat that as verified so the /// button isn't disabled in the brief window before authState lands. @@ -147,6 +188,15 @@ struct ComposeView: View { crossPostSection } + if canUseAI && !isReply && !isRepost { + AISeriesGeneratorSection( + content: content, + crossPost: aiCrossPostSelection, + maxMessageLength: maxMessageLength, + onCreated: handleAICreated + ) + } + if let error = errorMessage { Section { Text(error) @@ -186,6 +236,7 @@ struct ComposeView: View { .task { await loadIdentitiesIfNeeded() await loadPostableOrgs() + if canUseAI { await ai.loadStatusIfNeeded() } } .alert(successTitle, isPresented: $showSuccess) { Button("OK") { @@ -340,6 +391,9 @@ struct ComposeView: View { Text("\(remainingCharacters) characters remaining") .font(.ilMono()) .foregroundStyle(.secondary) + if canUseAI { + AIWritingAssistantButton(content: $content, onAddTags: applySuggestedTags) + } if canUseSubscriberFeatures { Button { withAnimation(.easeInOut(duration: 0.25)) { @@ -679,6 +733,18 @@ struct ComposeView: View { } } + /// A generated series lands server-side; pull the affected store back into + /// sync so the new list, folder, or scheduled posts show up without a manual + /// refresh. Scheduled messages live in their own view and need no prefetch. + private func handleAICreated(_ created: AICreated) { + if created.listId != nil { + Task { await store.refreshLists() } + } + if created.folderId != nil || created.documentId != nil { + Task { await store.refreshDocuments() } + } + } + private func postMessage() async { errorMessage = nil isLoading = true diff --git a/InterlinedList/Views/CreateListView.swift b/InterlinedList/Views/CreateListView.swift index e16c01b..e7a9575 100644 --- a/InterlinedList/Views/CreateListView.swift +++ b/InterlinedList/Views/CreateListView.swift @@ -5,11 +5,32 @@ import SwiftUI +/// Which builder the New List sheet is showing. Powered Templates appear only +/// for accounts with the AI entitlement — for everyone else the picker is absent +/// and the sheet is the blank builder it has always been. +private enum CreateListMode: String, CaseIterable, Identifiable { + case blank + case powered + + var id: String { rawValue } + + var label: String { + switch self { + case .blank: return "Blank" + case .powered: return "AI Template" + } + } +} + struct CreateListView: View { - let onCreate: (UserList) -> Void + /// Receives the new list, or `nil` when it was created server-side from an AI + /// artifact and could not be re-fetched — either way the list exists. + let onCreate: (UserList?) -> Void @EnvironmentObject private var authState: AuthState + @ObservedObject private var ai = AIService.shared @Environment(\.dismiss) private var dismiss + @State private var mode: CreateListMode = .blank @State private var name = "" @State private var description = "" @State private var isPublic = true @@ -27,6 +48,7 @@ struct CreateListView: View { private var isSubscriber: Bool { authState.user?.isSubscriber == true } private var hasGitHubIdentity: Bool { authState.hasGitHubIdentity } private var canOfferGitHub: Bool { isSubscriber && hasGitHubIdentity } + private var canUseAI: Bool { ai.isAvailable(for: authState.user) } private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -42,41 +64,11 @@ struct CreateListView: View { var body: some View { NavigationStack { - Form { - Section { - TextField("Name", text: $name) - TextField("Description (optional)", text: $description) - Toggle("Public", isOn: $isPublic) - } - - githubSection - - if !githubBacked { - columnsSection - } - - if let error = errorMessage { - Section { - Text(error) - .foregroundStyle(.red) - .font(.ilMono()) - } - } - - Section { - Button { - Task { await create() } - } label: { - HStack { - if isLoading { - ProgressView() - .frame(width: 20, height: 20) - } - Text("Create") - .frame(maxWidth: .infinity) - } - } - .disabled(!canCreate) + Group { + if mode == .powered { + AIPoweredTemplateView(onCreate: onCreate) + } else { + blankListForm } } .navigationTitle("New List") @@ -85,9 +77,61 @@ struct CreateListView: View { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + if canUseAI { + ToolbarItem(placement: .principal) { + Picker("Builder", selection: $mode) { + ForEach(CreateListMode.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + .accessibilityLabel("List builder") + } + } } .task { if isSubscriber { await authState.loadLinkedProvidersIfNeeded() } + if canUseAI { await ai.loadStatusIfNeeded() } + } + } + } + + private var blankListForm: some View { + Form { + Section { + TextField("Name", text: $name) + TextField("Description (optional)", text: $description) + Toggle("Public", isOn: $isPublic) + } + + githubSection + + if !githubBacked { + columnsSection + } + + if let error = errorMessage { + Section { + Text(error) + .foregroundStyle(.red) + .font(.ilMono()) + } + } + + Section { + Button { + Task { await create() } + } label: { + HStack { + if isLoading { + ProgressView() + .frame(width: 20, height: 20) + } + Text("Create") + .frame(maxWidth: .infinity) + } + } + .disabled(!canCreate) } } } diff --git a/InterlinedList/Views/DocumentsView.swift b/InterlinedList/Views/DocumentsView.swift index e697166..0604e28 100644 --- a/InterlinedList/Views/DocumentsView.swift +++ b/InterlinedList/Views/DocumentsView.swift @@ -8,10 +8,13 @@ import SwiftUI struct DocumentsView: View { @EnvironmentObject var authState: AuthState @EnvironmentObject var store: AppDataStore + @ObservedObject private var ai = AIService.shared @State private var showCreate = false @State private var showCreateFolder = false @State private var showTemplatePicker = false - @State private var createdFromTemplate: Document? + @State private var showPoweredDocument = false + /// The document just created from a template or by the AI, opened on arrival. + @State private var createdDocument: Document? @State private var folderToDelete: DocumentFolder? @State private var showDeleteFolderConfirm = false @State private var searchText = "" @@ -26,6 +29,10 @@ struct DocumentsView: View { authState.user?.isSubscriber == true } + /// AI drafts documents at the root, so this entry point lives here and not + /// inside `DocumentFolderView`. + private var canUseAI: Bool { ai.isAvailable(for: authState.user) } + // Folders are a subscriber-only feature. For free users we surface no // folder UI and treat every document as root-level (regardless of its // folderId on the server — the data is preserved, just not exposed). @@ -87,6 +94,9 @@ struct DocumentsView: View { } } .navigationTitle("Documents") + .task { + if canUseAI { await ai.loadStatusIfNeeded() } + } .searchable(text: $searchText, prompt: "Search documents") .onSubmit(of: .search) { Task { await runSearch(reset: true) } @@ -127,6 +137,13 @@ struct DocumentsView: View { Label("New Folder", systemImage: "folder.badge.plus") } } + if canUseAI { + Button { + showPoweredDocument = true + } label: { + Label("Powered Document", systemImage: "sparkles") + } + } } label: { Image(systemName: "plus") .font(.system(size: 15, weight: .semibold)) @@ -148,10 +165,22 @@ struct DocumentsView: View { .sheet(isPresented: $showTemplatePicker) { TemplatePickerView(targetFolderId: nil) { newDoc in store.insertDocument(newDoc) - createdFromTemplate = newDoc + createdDocument = newDoc + } + } + .sheet(isPresented: $showPoweredDocument) { + AIPoweredDocumentSheet { newDoc in + if let newDoc { + store.insertDocument(newDoc) + createdDocument = newDoc + } else { + Task { await store.refreshDocuments() } + } } + .environmentObject(authState) + .environmentObject(store) } - .navigationDestination(item: $createdFromTemplate) { doc in + .navigationDestination(item: $createdDocument) { doc in DocumentDetailView(document: doc, onUpdate: { updated in store.updateDocument(updated) }, onDelete: { id in diff --git a/InterlinedListTests/APIClientTests/APIClientAITests.swift b/InterlinedListTests/APIClientTests/APIClientAITests.swift new file mode 100644 index 0000000..548e5b8 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientAITests.swift @@ -0,0 +1,427 @@ +import XCTest +@testable import InterlinedList + +final class APIClientAITests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + private func requestBody() throws -> [String: Any] { + let data = try XCTUnwrap(session.lastRequest?.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + // MARK: - aiStatus + + func test_aiStatus_sendsAuthenticatedGet() async throws { + session.stub(json: #"{"subscriber":true,"providers":["anthropic"],"defaultModels":{},"quota":{"usedToday":0,"dailyLimit":50,"remaining":50}}"#) + _ = try await sut.aiStatus() + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/ai/status") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_aiStatus_decodesLiveShape() async throws { + session.stub(json: #""" + {"subscriber":true,"providers":["anthropic"], + "defaultModels":{"anthropic":"claude-sonnet-5"}, + "quota":{"usedToday":3,"dailyLimit":50,"remaining":47}} + """#) + let status = try await sut.aiStatus() + XCTAssertTrue(status.subscriber) + XCTAssertEqual(status.providers, ["anthropic"]) + XCTAssertEqual(status.defaultModels?["anthropic"], "claude-sonnet-5") + XCTAssertEqual(status.quota?.usedToday, 3) + XCTAssertEqual(status.quota?.remaining, 47) + XCTAssertEqual(status.attribution, "Powered by Claude Sonnet 5") + } + + func test_aiStatus_freeUserDecodesWithoutSubscriber() async throws { + session.stub(json: #"{"subscriber":false,"providers":[],"defaultModels":{},"quota":{"usedToday":0,"dailyLimit":50,"remaining":50}}"#) + let status = try await sut.aiStatus() + XCTAssertFalse(status.subscriber) + XCTAssertNil(status.attribution) + } + + // MARK: - aiSuggest + + func test_aiSuggest_postsFeatureInputAndContext() async throws { + session.stub(json: #"{"ok":true,"feature":"writing_assist","artifact":{"kind":"message","content":"Tighter."}}"#) + _ = try await sut.aiSuggest( + feature: .writingAssist, + input: "draft text", + context: .writingAssist(.tighten) + ) + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/ai/suggest") + let body = try requestBody() + XCTAssertEqual(body["feature"] as? String, "writing_assist") + XCTAssertEqual(body["input"] as? String, "draft text") + let context = try XCTUnwrap(body["context"] as? [String: Any]) + XCTAssertEqual(context["action"] as? String, "tighten") + // Unset context fields are omitted, not sent as null. + XCTAssertNil(context["listId"]) + XCTAssertNil(context["mode"]) + } + + func test_aiSuggest_omitsContextWhenAbsent() async throws { + session.stub(json: #"{"artifact":{"kind":"message","content":"x"}}"#) + _ = try await sut.aiSuggest(feature: .writingAssist, input: "hello") + XCTAssertNil(try requestBody()["context"]) + } + + func test_aiSuggest_decodesTagsArtifact() async throws { + session.stub(json: #"{"ok":true,"artifact":{"kind":"tags","tags":["swift","ios"]}}"#) + let suggestion = try await sut.aiSuggest(feature: .writingAssist, input: "x") + XCTAssertEqual(suggestion.artifact, .tags(["swift", "ios"])) + } + + func test_aiSuggest_decodesThreadArtifact() async throws { + session.stub(json: #"{"artifact":{"kind":"thread","parts":["one","two"]}}"#) + let suggestion = try await sut.aiSuggest(feature: .writingAssist, input: "x") + XCTAssertEqual(suggestion.artifact, .thread(["one", "two"])) + } + + func test_aiSuggest_decodesUsageAndQuota() async throws { + session.stub(json: #""" + {"artifact":{"kind":"message","content":"x"}, + "usage":{"inputTokens":12,"outputTokens":34,"model":"claude-sonnet-5"}, + "quota":{"usedToday":4,"dailyLimit":50}} + """#) + let suggestion = try await sut.aiSuggest(feature: .writingAssist, input: "x") + XCTAssertEqual(suggestion.usage?.outputTokens, 34) + XCTAssertEqual(suggestion.quota?.usedToday, 4) + // /suggest omits `remaining`; it falls back to the difference. + XCTAssertEqual(suggestion.quota?.remaining, 46) + } + + func test_aiSuggest_decodesMessageSeriesArtifact() async throws { + session.stub(json: #""" + {"artifact":{"kind":"message_series","listTitle":"Launch week", + "items":[{"order":1,"content":"first","crossPostTargets":["Bluesky"]}, + {"order":2,"content":"second"}]}} + """#) + let suggestion = try await sut.aiSuggest(feature: .messageSeries, input: "x") + guard case .messageSeries(let series) = suggestion.artifact else { + return XCTFail("expected a message_series artifact") + } + XCTAssertEqual(series.listTitle, "Launch week") + XCTAssertEqual(series.items.count, 2) + XCTAssertEqual(series.items[0].crossPostTargets, ["Bluesky"]) + XCTAssertNil(series.items[1].crossPostTargets) + } + + func test_aiSuggest_decodesDocSeriesArtifact() async throws { + session.stub(json: #""" + {"artifact":{"kind":"doc_series","folderTitle":"Series", + "documents":[{"order":1,"title":"Part one","outline":["intro"]}, + {"order":2,"title":"Part two","markdown":"# Two"}]}} + """#) + let suggestion = try await sut.aiSuggest(feature: .articleSeries, input: "x") + guard case .docSeries(let series) = suggestion.artifact else { + return XCTFail("expected a doc_series artifact") + } + XCTAssertEqual(series.folderTitle, "Series") + XCTAssertEqual(series.documents[0].outline, ["intro"]) + XCTAssertEqual(series.documents[1].markdown, "# Two") + } + + func test_aiSuggest_decodesDocumentArtifact() async throws { + session.stub(json: #""" + {"artifact":{"kind":"document","title":"Runbook","markdown":"# Runbook", + "outline":["Step 1"],"isPublic":false}} + """#) + let suggestion = try await sut.aiSuggest(feature: .poweredDocument, input: "x") + XCTAssertEqual( + suggestion.artifact, + .document(AIDocumentArtifact(title: "Runbook", markdown: "# Runbook", outline: ["Step 1"], isPublic: false)) + ) + } + + func test_aiSuggest_messageSeriesSendsChannelLabels() async throws { + session.stub(json: #"{"artifact":{"kind":"message_series","listTitle":"t","items":[]}}"#) + _ = try await sut.aiSuggest( + feature: .messageSeries, + input: "a draft with plenty of words in it", + context: .messageSeries(channels: ["Bluesky", "X/Twitter"]) + ) + let context = try XCTUnwrap(try requestBody()["context"] as? [String: Any]) + XCTAssertEqual(context["channels"] as? [String], ["Bluesky", "X/Twitter"]) + } + + // MARK: - Powered Template round-trip + + /// The DSL a Powered Template suggestion returns must reach `/generate` + /// byte-faithfully: the server re-validates it, and a rewritten field key + /// ("scheduled_at" → "scheduledAt") fails that check. + func test_poweredTemplate_dslRoundTripsWithoutKeyMangling() async throws { + session.stub(json: #""" + {"artifact":{"kind":"list","title":"Talks","description":"Conference talks", + "dsl":{"name":"Talks","fields":[ + {"key":"event_name","label":"Event","type":"text","required":true}, + {"key":"scheduled_at","label":"Date","type":"date"}, + {"key":"status","label":"Status","type":"select","options":["Draft","Confirmed"]} + ]}, + "rows":[{"event_name":"Deploy Conf","scheduled_at":"2026-10-01","status":"Draft"}]}} + """#) + let suggestion = try await sut.aiSuggest(feature: .poweredTemplate, input: "conference talks") + + session.stub(json: #"{"ok":true,"created":{"listId":"list-1"}}"#) + _ = try await sut.aiGenerate(feature: .poweredTemplate, artifact: suggestion.artifact) + + let body = try requestBody() + let artifact = try XCTUnwrap(body["artifact"] as? [String: Any]) + XCTAssertEqual(artifact["kind"] as? String, "list") + XCTAssertEqual(artifact["title"] as? String, "Talks") + let dsl = try XCTUnwrap(artifact["dsl"] as? [String: Any]) + let fields = try XCTUnwrap(dsl["fields"] as? [[String: Any]]) + XCTAssertEqual(fields.map { $0["key"] as? String }, ["event_name", "scheduled_at", "status"]) + XCTAssertEqual(fields[0]["required"] as? Bool, true) + XCTAssertEqual(fields[2]["options"] as? [String], ["Draft", "Confirmed"]) + let rows = try XCTUnwrap(artifact["rows"] as? [[String: Any]]) + XCTAssertEqual(rows[0]["scheduled_at"] as? String, "2026-10-01") + } + + func test_poweredTemplate_exposesFieldLabelsForPreview() async throws { + session.stub(json: #""" + {"artifact":{"kind":"list","title":"Talks", + "dsl":{"name":"Talks","fields":[{"key":"event","label":"Event","type":"text"}, + {"key":"venue","type":"text"}]}}} + """#) + let suggestion = try await sut.aiSuggest(feature: .poweredTemplate, input: "x") + guard case .list(let list) = suggestion.artifact else { return XCTFail("expected a list artifact") } + XCTAssertEqual(list.fieldLabels, ["Event", "venue"]) + } + + // MARK: - aiGenerate + + func test_aiGenerate_postsFeatureAndArtifact() async throws { + session.stub(json: #"{"ok":true,"created":{"documentId":"doc-1"},"quota":{"usedToday":6,"dailyLimit":50}}"#) + let result = try await sut.aiGenerate( + feature: .poweredDocument, + artifact: .document(AIDocumentArtifact(title: "T", markdown: "body", outline: nil, isPublic: nil)) + ) + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/ai/generate") + XCTAssertEqual(result.created.documentId, "doc-1") + XCTAssertEqual(result.quota?.remaining, 44) + let artifact = try XCTUnwrap(try requestBody()["artifact"] as? [String: Any]) + XCTAssertEqual(artifact["kind"] as? String, "document") + XCTAssertEqual(artifact["markdown"] as? String, "body") + // Optional artifact fields are omitted rather than sent as null. + XCTAssertNil(artifact["outline"]) + } + + func test_aiGenerate_messageSeriesSendsCrossPostAndScheduleFlag() async throws { + session.stub(json: #""" + {"created":{"scheduledMessageIds":["m1","m2"], + "firstScheduledAt":"2026-09-05T18:00:00.000Z", + "lastScheduledAt":"2026-09-05T18:40:00.000Z"}} + """#) + let crossPost = AIComposerCrossPost( + crossPostToBluesky: true, + selectedMastodonIds: ["ident-1"], + crossPostToTwitter: nil, + crossPostToLinkedIn: true, + selectedLinkedInTargets: [.orgPage(pageId: "page-9")], + linkedInLinkAsFirstComment: true + ) + let result = try await sut.aiGenerate( + feature: .messageSeries, + artifact: .messageSeries(AIMessageSeriesArtifact( + listTitle: "Launch", + items: [AIMessageSeriesItem(order: 1, content: "one", scheduledAt: nil, crossPostTargets: nil)] + )), + channels: ["Bluesky", "LinkedIn"], + scheduleImmediately: true, + crossPost: crossPost + ) + XCTAssertEqual(result.created.scheduledMessageIds, ["m1", "m2"]) + XCTAssertEqual(result.created.firstScheduledAt, "2026-09-05T18:00:00.000Z") + + let body = try requestBody() + XCTAssertEqual(body["scheduleImmediately"] as? Bool, true) + XCTAssertEqual(body["channels"] as? [String], ["Bluesky", "LinkedIn"]) + let sent = try XCTUnwrap(body["crossPost"] as? [String: Any]) + XCTAssertEqual(sent["crossPostToBluesky"] as? Bool, true) + XCTAssertEqual(sent["selectedMastodonIds"] as? [String], ["ident-1"]) + XCTAssertEqual(sent["linkedInLinkAsFirstComment"] as? Bool, true) + XCTAssertNil(sent["crossPostToTwitter"]) + let targets = try XCTUnwrap(sent["selectedLinkedInTargets"] as? [[String: Any]]) + XCTAssertEqual(targets[0]["kind"] as? String, "orgPage") + XCTAssertEqual(targets[0]["pageId"] as? String, "page-9") + } + + func test_aiGenerate_omitsSeriesOnlyFieldsForOtherFeatures() async throws { + session.stub(json: #"{"created":{"listId":"l1"}}"#) + _ = try await sut.aiGenerate( + feature: .poweredTemplate, + artifact: .list(AIListArtifact(title: "T", description: nil, dsl: .object([:]), rows: nil)) + ) + let body = try requestBody() + XCTAssertNil(body["crossPost"]) + XCTAssertNil(body["scheduleImmediately"]) + XCTAssertNil(body["channels"]) + } + + func test_aiGenerate_decodesArticleSeriesFolder() async throws { + session.stub(json: #"{"created":{"folderId":"f1","documentIds":["d1","d2","d3"]}}"#) + let result = try await sut.aiGenerate( + feature: .articleSeries, + artifact: .docSeries(AIDocSeriesArtifact(folderTitle: "S", documents: [])) + ) + XCTAssertEqual(result.created.folderId, "f1") + XCTAssertEqual(result.created.documentIds?.count, 3) + } + + // MARK: - Error mapping (§5.1 table) + + private func assertSuggestFails( + status: Int, + body: String, + headers: [String: String]? = nil, + expected: AIServiceError, + line: UInt = #line + ) async { + session.stub(json: body, statusCode: status, headers: headers) + do { + _ = try await sut.aiSuggest(feature: .writingAssist, input: "x") + XCTFail("expected a failure", line: line) + } catch let error as AIServiceError { + XCTAssertEqual(error, expected, line: line) + } catch { + XCTFail("expected AIServiceError, got \(error)", line: line) + } + } + + func test_error_subscriptionRequired() async { + await assertSuggestFails( + status: 403, + body: #"{"error":"An InterlinedList subscription is required for AI features.","code":"subscription_required"}"#, + expected: .subscriptionRequired + ) + } + + /// The subscription gate's server copy steers toward a purchase; it must never + /// reach the UI (App Store Guideline 3.1.1). + func test_error_subscriptionRequired_neverSurfacesBackendUpsellCopy() { + let message = AIServiceError.subscriptionRequired.userMessage + XCTAssertFalse(message.lowercased().contains("subscription")) + XCTAssertFalse(message.lowercased().contains("subscribe")) + XCTAssertFalse(message.lowercased().contains("upgrade")) + XCTAssertTrue(AIServiceError.subscriptionRequired.hidesAffordance) + } + + func test_error_noProviderConfigured_isTransientOutage() async { + await assertSuggestFails( + status: 409, + body: #"{"error":"AI is not configured on this server.","code":"no_provider_configured"}"#, + expected: .unavailable + ) + // Never a user-fixable state: no key-entry prompt, and the control retires. + XCTAssertTrue(AIServiceError.unavailable.hidesAffordance) + XCTAssertFalse(AIServiceError.unavailable.userMessage.lowercased().contains("key")) + } + + func test_error_invalidAiOutput_isRetryable() async { + await assertSuggestFails( + status: 422, + body: #"{"error":"Model did not return valid JSON.","code":"invalid_ai_output"}"#, + expected: .invalidOutput + ) + XCTAssertTrue(AIServiceError.invalidOutput.isRetryable) + } + + func test_error_refused_mapsToInvalidOutput() async { + await assertSuggestFails( + status: 422, + body: #"{"error":"Refused.","code":"refused"}"#, + expected: .invalidOutput + ) + } + + func test_error_invalidInput_keepsServerMessage() async { + await assertSuggestFails( + status: 422, + body: #"{"error":"A basic amount of content is required (at least 10 words).","code":"invalid_input"}"#, + expected: .invalidInput("A basic amount of content is required (at least 10 words).") + ) + } + + func test_error_quotaExceeded() async { + await assertSuggestFails( + status: 429, + body: #"{"error":"Daily limit reached.","code":"quota_exceeded"}"#, + expected: .quotaExceeded + ) + XCTAssertFalse(AIServiceError.quotaExceeded.isRetryable) + } + + func test_error_rateLimited_honorsRetryAfterHeader() async { + await assertSuggestFails( + status: 429, + body: #"{"error":"Rate limited. Retry in 42s.","code":"rate_limited"}"#, + headers: ["Retry-After": "42"], + expected: .rateLimited(retryAfter: 42) + ) + XCTAssertTrue(AIServiceError.rateLimited(retryAfter: 42).userMessage.contains("42s")) + } + + func test_error_rateLimited_withoutRetryAfterHeader() async { + await assertSuggestFails( + status: 429, + body: #"{"error":"Rate limited.","code":"rate_limited"}"#, + expected: .rateLimited(retryAfter: nil) + ) + } + + func test_error_providerError() async { + await assertSuggestFails( + status: 502, + body: #"{"error":"Upstream failure","code":"provider_error"}"#, + expected: .providerError + ) + } + + func test_error_internalErrorWithoutKnownCode_mapsByStatus() async { + await assertSuggestFails(status: 500, body: #"{"error":"Internal error"}"#, expected: .providerError) + } + + func test_error_unauthorized() async { + await assertSuggestFails( + status: 401, + body: #"{"error":"Unauthorized","code":"unauthorized"}"#, + expected: .unauthorized + ) + } + + func test_error_malformedSuccessBody_isInvalidOutput() async { + session.stub(json: #"{"artifact":{"kind":"nonsense"}}"#) + do { + _ = try await sut.aiSuggest(feature: .writingAssist, input: "x") + XCTFail("expected a failure") + } catch let error as AIServiceError { + XCTAssertEqual(error, .invalidOutput) + } catch { + XCTFail("expected AIServiceError, got \(error)") + } + } + + func test_aiStatus_errorsAreTypedToo() async { + session.stub(json: #"{"error":"Unauthorized","code":"unauthorized"}"#, statusCode: 401) + do { + _ = try await sut.aiStatus() + XCTFail("expected a failure") + } catch let error as AIServiceError { + XCTAssertEqual(error, .unauthorized) + } catch { + XCTFail("expected AIServiceError, got \(error)") + } + } +} diff --git a/InterlinedListTests/E2E/E2EReadOnlyTests.swift b/InterlinedListTests/E2E/E2EReadOnlyTests.swift index 7a688d4..cf047b6 100644 --- a/InterlinedListTests/E2E/E2EReadOnlyTests.swift +++ b/InterlinedListTests/E2E/E2EReadOnlyTests.swift @@ -61,6 +61,25 @@ final class E2EReadOnlyTests: XCTestCase { } } + // MARK: - AI (read-only) + + /// `GET /api/ai/status` is free, Bearer-ready, and the single gate for every + /// AI affordance — this pins the live payload to the client model. Read-only: + /// `/suggest` and `/generate` are never called here, since both spend the + /// account's daily AI quota and `/generate` writes. + func test_e2e_aiStatus_decodesLivePayload() async throws { + let status = try await client.aiStatus() + let user = try XCTUnwrap(Self.sharedUser) + // The server computes `subscriber` from the same customerStatus the app + // reads locally; the composer gates on the local flag before /status + // lands, so a mismatch would flash an affordance the API then refuses. + XCTAssertEqual(status.subscriber, user.isSubscriber) + let quota = try XCTUnwrap(status.quota, "status should report a quota") + XCTAssertGreaterThan(quota.dailyLimit, 0) + XCTAssertGreaterThanOrEqual(quota.usedToday, 0) + XCTAssertEqual(quota.remaining, max(0, quota.dailyLimit - quota.usedToday)) + } + // MARK: - Auth func test_e2e_login_succeedsAndReturnsToken() async throws { diff --git a/InterlinedListTests/MockURLSession.swift b/InterlinedListTests/MockURLSession.swift index fedfcdd..4490da4 100644 --- a/InterlinedListTests/MockURLSession.swift +++ b/InterlinedListTests/MockURLSession.swift @@ -7,44 +7,45 @@ import Foundation final class MockURLSession: URLSessionProtocol { private var stubbedData: Data = Data() private var stubbedStatusCode: Int = 200 - private var stubQueue: [(Data, Int)] = [] + private var stubbedHeaders: [String: String]? + private var stubQueue: [(Data, Int, [String: String]?)] = [] private(set) var lastRequest: URLRequest? private(set) var requestHistory: [URLRequest] = [] - func stub(data: Data, statusCode: Int = 200) { + func stub(data: Data, statusCode: Int = 200, headers: [String: String]? = nil) { stubbedData = data stubbedStatusCode = statusCode + stubbedHeaders = headers } - func stub(json: String, statusCode: Int = 200) { - stubbedData = Data(json.utf8) - stubbedStatusCode = statusCode + func stub(json: String, statusCode: Int = 200, headers: [String: String]? = nil) { + stub(data: Data(json.utf8), statusCode: statusCode, headers: headers) } - func enqueue(json: String, statusCode: Int = 200) { - stubQueue.append((Data(json.utf8), statusCode)) + func enqueue(json: String, statusCode: Int = 200, headers: [String: String]? = nil) { + stubQueue.append((Data(json.utf8), statusCode, headers)) } - func enqueue(data: Data, statusCode: Int = 200) { - stubQueue.append((data, statusCode)) + func enqueue(data: Data, statusCode: Int = 200, headers: [String: String]? = nil) { + stubQueue.append((data, statusCode, headers)) } func data(for request: URLRequest) async throws -> (Data, URLResponse) { lastRequest = request requestHistory.append(request) let url = request.url ?? URL(string: "https://interlinedlist.com")! - let (body, status): (Data, Int) + let (body, status, headers): (Data, Int, [String: String]?) if !stubQueue.isEmpty { - (body, status) = stubQueue.removeFirst() + (body, status, headers) = stubQueue.removeFirst() } else { - (body, status) = (stubbedData, stubbedStatusCode) + (body, status, headers) = (stubbedData, stubbedStatusCode, stubbedHeaders) } let response = HTTPURLResponse( url: url, statusCode: status, httpVersion: nil, - headerFields: nil + headerFields: headers )! return (body, response) } diff --git a/InterlinedListTests/ModelTests/AIArtifactTests.swift b/InterlinedListTests/ModelTests/AIArtifactTests.swift new file mode 100644 index 0000000..bfb5073 --- /dev/null +++ b/InterlinedListTests/ModelTests/AIArtifactTests.swift @@ -0,0 +1,162 @@ +import XCTest +@testable import InterlinedList + +final class AIArtifactTests: XCTestCase { + + private func encoded(_ artifact: AIArtifact) throws -> [String: Any] { + let data = try JSONEncoder().encode(artifact) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private func decoded(_ json: String) throws -> AIArtifact { + try JSONDecoder().decode(AIArtifact.self, from: Data(json.utf8)) + } + + func test_everyKindRoundTripsThroughItsDiscriminator() throws { + let cases: [(String, AIArtifact)] = [ + (#"{"kind":"message","content":"hello"}"#, .message("hello")), + (#"{"kind":"tags","tags":["a","b"]}"#, .tags(["a", "b"])), + (#"{"kind":"thread","parts":["one","two"]}"#, .thread(["one", "two"])), + ( + #"{"kind":"document","title":"T","markdown":"M","outline":["o"],"isPublic":true}"#, + .document(AIDocumentArtifact(title: "T", markdown: "M", outline: ["o"], isPublic: true)) + ), + ( + #"{"kind":"message_series","listTitle":"L","items":[{"order":1,"content":"c"}]}"#, + .messageSeries(AIMessageSeriesArtifact( + listTitle: "L", + items: [AIMessageSeriesItem(order: 1, content: "c", scheduledAt: nil, crossPostTargets: nil)] + )) + ), + ( + #"{"kind":"doc_series","folderTitle":"F","documents":[{"order":1,"title":"D"}]}"#, + .docSeries(AIDocSeriesArtifact( + folderTitle: "F", + documents: [AIDocSeriesDocument(order: 1, title: "D", outline: nil, markdown: nil)] + )) + ), + ( + #"{"kind":"list","title":"T","dsl":{"name":"T","fields":[]}}"#, + .list(AIListArtifact( + title: "T", + description: nil, + dsl: .object(["name": .string("T"), "fields": .array([])]), + rows: nil + )) + ), + ] + for (json, expected) in cases { + let artifact = try decoded(json) + XCTAssertEqual(artifact, expected, "decoding \(json)") + let reencoded = try encoded(artifact) + XCTAssertEqual(reencoded["kind"] as? String, expected.kind) + } + } + + func test_encodingKeepsPayloadFieldsAlongsideKind() throws { + let body = try encoded(.messageSeries(AIMessageSeriesArtifact( + listTitle: "Launch", + items: [AIMessageSeriesItem(order: 2, content: "second", scheduledAt: "2026-09-05T18:00", crossPostTargets: ["Bluesky"])] + ))) + XCTAssertEqual(body["kind"] as? String, "message_series") + XCTAssertEqual(body["listTitle"] as? String, "Launch") + let items = try XCTUnwrap(body["items"] as? [[String: Any]]) + XCTAssertEqual(items[0]["order"] as? Int, 2) + XCTAssertEqual(items[0]["scheduledAt"] as? String, "2026-09-05T18:00") + XCTAssertEqual(items[0]["crossPostTargets"] as? [String], ["Bluesky"]) + } + + func test_unknownKindFailsToDecode() { + XCTAssertThrowsError(try decoded(#"{"kind":"video","url":"x"}"#)) + } + + func test_listArtifactFieldLabelsFallBackToKey() throws { + let artifact = try decoded(#""" + {"kind":"list","title":"T","dsl":{"fields":[{"key":"a","label":"Alpha"},{"key":"b"}]}} + """#) + guard case .list(let list) = artifact else { return XCTFail("expected a list artifact") } + XCTAssertEqual(list.fieldLabels, ["Alpha", "b"]) + } + + func test_listArtifactExposesColumnMetadataForThePreview() throws { + let artifact = try decoded(#""" + {"kind":"list","title":"Campaign","dsl":{"fields":[ + {"key":"message","label":"Message","type":"textarea","required":true}, + {"key":"channels","label":"Channels","type":"multiselect","options":["Bluesky","Mastodon"]}, + {"key":"scheduled_at","label":"Scheduled At","type":"datetime"} + ]}} + """#) + guard case .list(let list) = artifact else { return XCTFail("expected a list artifact") } + XCTAssertEqual(list.fields.map(\.id), ["message", "channels", "scheduled_at"]) + XCTAssertEqual(list.fields[0].type, "textarea") + XCTAssertTrue(list.fields[0].isRequired) + XCTAssertFalse(list.fields[1].isRequired) + XCTAssertEqual(list.fields[1].options, ["Bluesky", "Mastodon"]) + XCTAssertTrue(list.fields[2].options.isEmpty) + } + + func test_listArtifactRowSummaryPairsValuesWithLabelsInSchemaOrder() throws { + let artifact = try decoded(#""" + {"kind":"list","title":"Campaign", + "dsl":{"fields":[{"key":"message","label":"Message","type":"text"}, + {"key":"channels","label":"Channels","type":"multiselect"}, + {"key":"notes","label":"Notes","type":"text"}]}, + "rows":[{"channels":["Bluesky","Mastodon"],"message":"Launch day","notes":null}]} + """#) + guard case .list(let list) = artifact, let row = list.rows?.first else { + return XCTFail("expected a list artifact with a row") + } + let summary = list.rowSummary(row) + XCTAssertEqual(summary.map(\.label), ["Message", "Channels"]) + XCTAssertEqual(summary.map(\.value), ["Launch day", "Bluesky, Mastodon"]) + } + + func test_listArtifactWithoutDslFieldsHasNoLabels() throws { + let artifact = try decoded(#"{"kind":"list","title":"T","dsl":{"name":"T"}}"#) + guard case .list(let list) = artifact else { return XCTFail("expected a list artifact") } + XCTAssertTrue(list.fieldLabels.isEmpty) + } + + // MARK: - AICreated + + func test_createdDecodesEachShape() throws { + let list = try JSONDecoder().decode(AICreated.self, from: Data(#"{"listId":"l"}"#.utf8)) + XCTAssertEqual(list.listId, "l") + XCTAssertNil(list.documentId) + + let doc = try JSONDecoder().decode(AICreated.self, from: Data(#"{"documentId":"d"}"#.utf8)) + XCTAssertEqual(doc.documentId, "d") + + let folder = try JSONDecoder().decode(AICreated.self, from: Data(#"{"folderId":"f","documentIds":["a"]}"#.utf8)) + XCTAssertEqual(folder.folderId, "f") + XCTAssertEqual(folder.documentIds, ["a"]) + + let scheduled = try JSONDecoder().decode( + AICreated.self, + from: Data(#"{"scheduledMessageIds":["m"],"firstScheduledAt":"t1","lastScheduledAt":"t2"}"#.utf8) + ) + XCTAssertEqual(scheduled.scheduledMessageIds, ["m"]) + XCTAssertEqual(scheduled.lastScheduledAt, "t2") + } + + // MARK: - Quota + + func test_quotaFallsBackToDifferenceWhenRemainingAbsent() throws { + let quota = try JSONDecoder().decode(AIQuota.self, from: Data(#"{"usedToday":7,"dailyLimit":50}"#.utf8)) + XCTAssertEqual(quota.remaining, 43) + XCTAssertFalse(quota.isExhausted) + } + + func test_quotaPrefersReportedRemaining() throws { + let quota = try JSONDecoder().decode( + AIQuota.self, + from: Data(#"{"usedToday":7,"dailyLimit":50,"remaining":0}"#.utf8) + ) + XCTAssertEqual(quota.remaining, 0) + XCTAssertTrue(quota.isExhausted) + } + + func test_quotaNeverReportsNegativeRemaining() { + XCTAssertEqual(AIQuota(usedToday: 60, dailyLimit: 50).remaining, 0) + } +} diff --git a/InterlinedListTests/ModelTests/AIComposerLogicTests.swift b/InterlinedListTests/ModelTests/AIComposerLogicTests.swift new file mode 100644 index 0000000..62cd181 --- /dev/null +++ b/InterlinedListTests/ModelTests/AIComposerLogicTests.swift @@ -0,0 +1,151 @@ +import XCTest +@testable import InterlinedList + +/// The client-side gates and derivations that decide what the composer sends — +/// each one mirrors a server rule, so a drift here shows up as a wasted quota +/// unit or a mis-sized series. +final class AIComposerLogicTests: XCTestCase { + + // MARK: - Word gate (COMPOSER_MIN_WORDS) + + func test_countWordsIgnoresExtraWhitespaceAndNewlines() { + XCTAssertEqual(AILimits.countWords(""), 0) + XCTAssertEqual(AILimits.countWords(" "), 0) + XCTAssertEqual(AILimits.countWords("one"), 1) + XCTAssertEqual(AILimits.countWords("one two\n\nthree"), 3) + } + + func test_seriesMinimumMatchesServerThreshold() { + XCTAssertEqual(AILimits.composerMinWords, 10) + let nineWords = "one two three four five six seven eight nine" + let tenWords = nineWords + " ten" + XCTAssertFalse(AILimits.meetsSeriesMinimum(nineWords)) + XCTAssertTrue(AILimits.meetsSeriesMinimum(tenWords)) + } + + func test_perFeatureInputWordCeilingsMatchTheServer() { + XCTAssertEqual(AILimits.maxInputWords(.writingAssist), 1500) + XCTAssertEqual(AILimits.maxInputWords(.poweredTemplate), 300) + XCTAssertEqual(AILimits.maxInputWords(.messageSeries), 500) + XCTAssertEqual(AILimits.maxInputWords(.poweredDocument), 500) + XCTAssertEqual(AILimits.maxInputWords(.articleSeries), 500) + } + + // MARK: - Cross-post selection → channel labels + + func test_channelLabelsMirrorTheServerMapping() { + let selection = AIComposerCrossPost( + crossPostToBluesky: true, + selectedMastodonIds: ["m1"], + crossPostToTwitter: true, + crossPostToLinkedIn: true + ) + XCTAssertEqual(selection.channelLabels, ["Bluesky", "Mastodon", "LinkedIn", "X/Twitter"]) + } + + func test_channelLabelsIgnoreEmptyMastodonSelection() { + let selection = AIComposerCrossPost(crossPostToBluesky: true, selectedMastodonIds: []) + XCTAssertEqual(selection.channelLabels, ["Bluesky"]) + } + + func test_emptySelectionHasNoLabels() { + XCTAssertTrue(AIComposerCrossPost().channelLabels.isEmpty) + XCTAssertTrue(AIComposerCrossPost().isEmpty) + XCTAssertFalse(AIComposerCrossPost(crossPostToTwitter: true).isEmpty) + } + + // MARK: - Series sizing + + func test_charLimitIsTheTightestSelectedService() { + XCTAssertEqual(AICrossPostLimits.minCharLimit(labels: ["Bluesky", "LinkedIn"], fallback: 666), 300) + XCTAssertEqual(AICrossPostLimits.minCharLimit(labels: ["LinkedIn"], fallback: 666), 3000) + XCTAssertEqual(AICrossPostLimits.minCharLimit(labels: ["X/Twitter", "Mastodon"], fallback: 666), 280) + } + + func test_charLimitFallsBackWhenNothingMapsToAChannel() { + XCTAssertEqual(AICrossPostLimits.minCharLimit(labels: [], fallback: 666), 666) + XCTAssertEqual(AICrossPostLimits.minCharLimit(labels: ["Blog", "Newsletter"], fallback: 666), 666) + } + + func test_charLimitAcceptsTheAliasesTheServerAccepts() { + XCTAssertEqual(AICrossPostLimits.minCharLimit(labels: ["twitter"], fallback: 666), 280) + XCTAssertEqual(AICrossPostLimits.minCharLimit(labels: [" X "], fallback: 666), 280) + XCTAssertEqual(AICrossPostLimits.minCharLimit(labels: ["bluesky"], fallback: 666), 300) + } + + // MARK: - Loading captions + + func test_loadingStepAdvancesThenClamps() { + let captions = AISeriesLoading.captions(for: .messageSeries) + XCTAssertEqual(AISeriesLoading.stepIndex(elapsed: 0, captionCount: captions.count), 0) + XCTAssertEqual(AISeriesLoading.stepIndex(elapsed: 2.6, captionCount: captions.count), 1) + XCTAssertEqual(AISeriesLoading.stepIndex(elapsed: 7.6, captionCount: captions.count), 3) + XCTAssertEqual(AISeriesLoading.stepIndex(elapsed: 600, captionCount: captions.count), captions.count - 1) + } + + func test_loadingStepHandlesDegenerateInput() { + XCTAssertEqual(AISeriesLoading.stepIndex(elapsed: -5, captionCount: 4), 0) + XCTAssertEqual(AISeriesLoading.stepIndex(elapsed: 10, captionCount: 0), 0) + } + + func test_loadingCopyIsFeatureSpecific() { + XCTAssertTrue(AISeriesLoading.title(for: .messageSeries).contains("message series")) + XCTAssertTrue(AISeriesLoading.title(for: .articleSeries).contains("article series")) + XCTAssertNotEqual( + AISeriesLoading.captions(for: .messageSeries), + AISeriesLoading.captions(for: .articleSeries) + ) + } + + // MARK: - Post-generate confirmation copy + + func test_confirmationForScheduledMessages() { + let created = AICreated( + scheduledMessageIds: ["a", "b", "c"], + firstScheduledAt: "2026-09-05T18:00:00.000Z" + ) + let copy = AISeriesSheet.confirmation(for: created) + XCTAssertTrue(copy.hasPrefix("Scheduled 3 messages — first around "), copy) + } + + func test_confirmationSingularizesOneMessage() { + let created = AICreated(scheduledMessageIds: ["a"]) + XCTAssertEqual(AISeriesSheet.confirmation(for: created), "Scheduled 1 message.") + } + + func test_confirmationForListAndFolder() { + XCTAssertEqual( + AISeriesSheet.confirmation(for: AICreated(listId: "l1")), + "Message series list created." + ) + XCTAssertEqual( + AISeriesSheet.confirmation(for: AICreated(folderId: "f1", documentIds: ["a", "b"])), + "Article series created (2 documents)." + ) + } + + func test_confirmationFallsBackWhenServerReportsNothingKnown() { + XCTAssertEqual(AISeriesSheet.confirmation(for: AICreated()), "Created.") + } + + // MARK: - Context builders + + func test_contextBuildersSetOnlyTheirOwnFields() { + let assist = AIContext.writingAssist(.grammar) + XCTAssertEqual(assist.action, "grammar") + XCTAssertNil(assist.mode) + + let template = AIContext.poweredTemplate(templateKey: "article_series") + XCTAssertEqual(template.templateKey, "article_series") + XCTAssertNil(template.action) + + let series = AIContext.messageSeries(channels: ["Bluesky"]) + XCTAssertEqual(series.channels, ["Bluesky"]) + + let document = AIContext.poweredDocument(mode: .fromList, listId: "l1") + XCTAssertEqual(document.mode, "from_list") + XCTAssertEqual(document.listId, "l1") + XCTAssertNil(document.documentId) + XCTAssertNil(document.url) + } +} diff --git a/InterlinedListTests/ModelTests/AIJSONTests.swift b/InterlinedListTests/ModelTests/AIJSONTests.swift new file mode 100644 index 0000000..93c49eb --- /dev/null +++ b/InterlinedListTests/ModelTests/AIJSONTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import InterlinedList + +final class AIJSONTests: XCTestCase { + + private func roundTrip(_ json: String) throws -> String { + let value = try JSONDecoder().decode(AIJSON.self, from: Data(json.utf8)) + let encoded = try JSONEncoder().encode(value) + let object = try JSONSerialization.jsonObject(with: encoded) + let normalized = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + return String(decoding: normalized, as: UTF8.self) + } + + private func normalize(_ json: String) throws -> String { + let object = try JSONSerialization.jsonObject(with: Data(json.utf8)) + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + } + + func test_scalarsRoundTrip() throws { + let json = #"{"s":"text","i":42,"d":1.5,"b":true,"n":null}"# + XCTAssertEqual(try roundTrip(json), try normalize(json)) + } + + /// A whole number must not come back as `1.0` — the server re-parses the DSL + /// and a float where an integer belongs changes the schema. + func test_wholeNumbersStayIntegers() throws { + let value = try JSONDecoder().decode(AIJSON.self, from: Data(#"{"min":1,"step":50}"#.utf8)) + XCTAssertEqual(value.objectValue?["min"], .int(1)) + let encoded = String(decoding: try JSONEncoder().encode(value), as: UTF8.self) + XCTAssertTrue(encoded.contains("\"min\":1")) + XCTAssertFalse(encoded.contains("1.0")) + } + + func test_nestedObjectsAndArraysRoundTrip() throws { + let json = #""" + {"name":"Talks","fields":[ + {"key":"scheduled_at","type":"date","validation":{"min":1,"step":1}}, + {"key":"status","type":"select","options":["Draft","Published"]}, + {"key":"blocked_by","visibility":{"condition":{"field":"status","operator":"equals","value":"Blocked"}}} + ]} + """# + XCTAssertEqual(try roundTrip(json), try normalize(json)) + } + + /// Snake_case keys inside the DSL are data, not Swift property names. + func test_snakeCaseKeysArePreserved() throws { + let value = try JSONDecoder().decode(AIJSON.self, from: Data(#"{"scheduled_at":"2026-01-01"}"#.utf8)) + XCTAssertNotNil(value.objectValue?["scheduled_at"]) + XCTAssertNil(value.objectValue?["scheduledAt"]) + let encoded = String(decoding: try JSONEncoder().encode(value), as: UTF8.self) + XCTAssertTrue(encoded.contains("scheduled_at")) + } + + func test_topLevelArrayRoundTrips() throws { + let value = try JSONDecoder().decode(AIJSON.self, from: Data(#"[1,"two",false,null]"#.utf8)) + XCTAssertEqual(value.arrayValue?.count, 4) + XCTAssertEqual(value.arrayValue?[0], .int(1)) + XCTAssertEqual(value.arrayValue?[3], .null) + } + + func test_displayStringFlattensValues() { + XCTAssertEqual(AIJSON.string("hi").displayString, "hi") + XCTAssertEqual(AIJSON.int(3).displayString, "3") + XCTAssertEqual(AIJSON.double(2.0).displayString, "2") + XCTAssertEqual(AIJSON.double(2.5).displayString, "2.5") + XCTAssertEqual(AIJSON.bool(true).displayString, "Yes") + XCTAssertEqual(AIJSON.array([.string("a"), .string("b")]).displayString, "a, b") + XCTAssertEqual(AIJSON.null.displayString, "") + } +} diff --git a/InterlinedListTests/ServiceTests/AIServiceTests.swift b/InterlinedListTests/ServiceTests/AIServiceTests.swift new file mode 100644 index 0000000..a5538e3 --- /dev/null +++ b/InterlinedListTests/ServiceTests/AIServiceTests.swift @@ -0,0 +1,181 @@ +import XCTest +@testable import InterlinedList + +@MainActor +final class AIServiceTests: XCTestCase { + var session: MockURLSession! + var sut: AIService! + + private static let subscriberStatus = #""" + {"subscriber":true,"providers":["anthropic"], + "defaultModels":{"anthropic":"claude-sonnet-5"}, + "quota":{"usedToday":2,"dailyLimit":50,"remaining":48}} + """# + + private static let freeStatus = #""" + {"subscriber":false,"providers":[],"defaultModels":{}, + "quota":{"usedToday":0,"dailyLimit":50,"remaining":50}} + """# + + override func setUp() { + super.setUp() + session = MockURLSession() + let api = APIClient(session: session) + api.setBearerToken("tok") + sut = AIService(api: api) + } + + private func user(subscriber: Bool) -> User { + User( + id: "u1", email: "a@b.co", username: "u", displayName: nil, avatar: nil, + bio: nil, theme: nil, emailVerified: true, createdAt: nil, + maxMessageLength: 666, showAdvancedPostSettings: nil, defaultPubliclyVisible: nil, + customerStatus: subscriber ? "subscriber:monthly" : "free" + ) + } + + // MARK: - Gating + + func test_isAvailable_usesLocalSubscriberFlagBeforeStatusLoads() { + XCTAssertTrue(sut.isAvailable(for: user(subscriber: true))) + XCTAssertFalse(sut.isAvailable(for: user(subscriber: false))) + XCTAssertFalse(sut.isAvailable(for: nil)) + } + + func test_isAvailable_loadedStatusSupersedesStaleLocalFlag() async { + session.stub(json: Self.freeStatus) + await sut.refreshStatus() + XCTAssertFalse(sut.isAvailable(for: user(subscriber: true))) + } + + func test_isAvailable_statusGrantsAccessWhenLocalUserLooksFree() async { + session.stub(json: Self.subscriberStatus) + await sut.refreshStatus() + XCTAssertTrue(sut.isAvailable(for: user(subscriber: false))) + } + + func test_loadStatusIfNeeded_fetchesOnceOnly() async { + session.stub(json: Self.subscriberStatus) + await sut.loadStatusIfNeeded() + await sut.loadStatusIfNeeded() + XCTAssertEqual(session.requestHistory.filter { $0.url?.path == "/api/ai/status" }.count, 1) + } + + /// Composer, list builder and documents all ask on appear; one request serves + /// them all rather than three racing. + func test_concurrentStatusLoadsShareOneRequest() async { + session.stub(json: Self.subscriberStatus) + async let first: Void = sut.loadStatusIfNeeded() + async let second: Void = sut.loadStatusIfNeeded() + async let third: Void = sut.refreshStatus() + _ = await (first, second, third) + XCTAssertEqual(session.requestHistory.filter { $0.url?.path == "/api/ai/status" }.count, 1) + XCTAssertEqual(sut.quota?.remaining, 48) + } + + func test_refreshStatus_refetchesAfterAnEarlierLoadCompleted() async { + session.stub(json: Self.subscriberStatus) + await sut.loadStatusIfNeeded() + await sut.refreshStatus() + XCTAssertEqual(session.requestHistory.filter { $0.url?.path == "/api/ai/status" }.count, 2) + } + + func test_refreshStatus_populatesQuotaAndAttribution() async { + session.stub(json: Self.subscriberStatus) + await sut.refreshStatus() + XCTAssertEqual(sut.quota?.remaining, 48) + XCTAssertEqual(sut.attribution, "Powered by Claude Sonnet 5") + } + + /// A failed status read must not hide a control the user is entitled to. + func test_refreshStatus_transportFailureKeepsAffordanceVisible() async { + session.stub(json: #"{"error":"boom"}"#, statusCode: 500) + await sut.refreshStatus() + XCTAssertFalse(sut.isDisabledForSession) + XCTAssertTrue(sut.isAvailable(for: user(subscriber: true))) + } + + func test_refreshStatus_noProviderConfiguredRetiresAffordance() async { + session.stub(json: #"{"error":"AI is not configured.","code":"no_provider_configured"}"#, statusCode: 409) + await sut.refreshStatus() + XCTAssertTrue(sut.isDisabledForSession) + XCTAssertFalse(sut.isAvailable(for: user(subscriber: true))) + } + + // MARK: - suggest / generate + + func test_suggest_returnsArtifactAndUpdatesQuota() async throws { + session.stub(json: #""" + {"artifact":{"kind":"tags","tags":["swift"]},"quota":{"usedToday":9,"dailyLimit":50}} + """#) + let artifact = try await sut.suggest(feature: .writingAssist, input: "x", context: .writingAssist(.tags)) + XCTAssertEqual(artifact, .tags(["swift"])) + XCTAssertEqual(sut.quota?.usedToday, 9) + XCTAssertEqual(sut.quota?.remaining, 41) + } + + func test_generate_returnsCreatedAndUpdatesQuota() async throws { + session.stub(json: #"{"created":{"listId":"l1"},"quota":{"usedToday":10,"dailyLimit":50}}"#) + let created = try await sut.generate( + feature: .poweredTemplate, + artifact: .list(AIListArtifact(title: "T", description: nil, dsl: .object([:]), rows: nil)) + ) + XCTAssertEqual(created.listId, "l1") + XCTAssertEqual(sut.quota?.usedToday, 10) + } + + func test_suggest_subscriptionRequiredRetiresAffordanceForSession() async { + session.stub(json: #"{"error":"…","code":"subscription_required"}"#, statusCode: 403) + await XCTAssertThrowsErrorAsync(try await sut.suggest(feature: .writingAssist, input: "x")) + XCTAssertTrue(sut.isDisabledForSession) + XCTAssertFalse(sut.isAvailable(for: user(subscriber: true))) + } + + func test_suggest_quotaExceededZeroesRemaining() async { + session.stub(json: Self.subscriberStatus) + await sut.refreshStatus() + session.stub(json: #"{"error":"Daily limit reached.","code":"quota_exceeded"}"#, statusCode: 429) + await XCTAssertThrowsErrorAsync(try await sut.suggest(feature: .writingAssist, input: "x")) + XCTAssertEqual(sut.quota?.remaining, 0) + XCTAssertTrue(sut.quota?.isExhausted == true) + // A spent budget is not an entitlement failure — the control stays. + XCTAssertFalse(sut.isDisabledForSession) + } + + func test_suggest_retryableFailureLeavesAffordanceAlone() async { + session.stub(json: #"{"error":"junk","code":"invalid_ai_output"}"#, statusCode: 422) + await XCTAssertThrowsErrorAsync(try await sut.suggest(feature: .writingAssist, input: "x")) + XCTAssertFalse(sut.isDisabledForSession) + } + + func test_suggest_rateLimitedSurfacesRetryAfter() async { + session.stub( + json: #"{"error":"Rate limited.","code":"rate_limited"}"#, + statusCode: 429, + headers: ["Retry-After": "30"] + ) + do { + _ = try await sut.suggest(feature: .writingAssist, input: "x") + XCTFail("expected a failure") + } catch let error as AIServiceError { + XCTAssertEqual(error, .rateLimited(retryAfter: 30)) + } catch { + XCTFail("expected AIServiceError, got \(error)") + } + } +} + +// MARK: - Async throw assertion + +func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("expected an error", file: file, line: line) + } catch { + // Expected. + } +}