diff --git a/.gitignore b/.gitignore index 18b3988..f530e93 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,12 @@ DerivedData/ *.Trash *.xcworkspace/xcuserdata/*/UserInterfaceState.xcuserstate macai.xcodeproj/project.xcworkspace/xcuserdata/*/UserInterfaceState.xcuserstate +code-review/* + +# Local working notes (not part of the public repo) +temp/ +Assistant_changes.md +CODE_REVIEW.md +CoreData_FIX_Plan.md +GEMINI_SDK_DETAILS.md +IOS_IMPLEMENTATION_PLAN.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6b41962 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# Draft-Save Crash Fix (Core Data) + +## Summary +We fixed EXC_BAD_ACCESS / Core Data crashes tied to draft saving and iCloud sync. The primary issue was a Core Data attribute named **`newMessage`**, which conflicts with Objective‑C “new*” naming rules and can crash during Swift/Obj‑C bridging. Secondary fixes ensured Core Data access stays on the correct queue. + +## Root Cause +1) **Core Data attribute named `newMessage`**. Properties starting with `new*` are treated specially by Objective‑C (“Create Rule”). Core Data’s dynamic accessors and Swift bridging can crash when a model attribute uses a `new*` name. +2) Crashes appeared during SwiftUI updates and iCloud sync (forwarding / bridging failures such as `___forwarding___` + Swift string bridging). +3) Some Core Data fetches were performed off the context’s queue (e.g., in async tasks), triggering `_PFAssertSafeMultiThreadedAccess_impl`. + +## Fix Strategy +### A) Rename the Core Data draft field +`ChatEntity.newMessage` → `ChatEntity.draftMessage` (with `renamingIdentifier="newMessage"`). +This avoids Objective‑C “new*” naming collisions while preserving data during migration and sync. + +### B) Isolate draft saves in a background context +Drafts are saved in a private queue context with a unique `transactionAuthor`: +- `AppConstants.draftTransactionAuthor = "Drafts"` +- Draft saves use this context only. + +### C) Store only IDs in drafts +Drafts store message text + attachment **IDs**, not entire objects. This keeps draft saves lightweight and avoids merging attachment data into the UI context. + +### D) Fix Core Data queue violations +Every Core Data fetch from background threads now uses `perform` / `performAndWait` on the correct context. + +## Key Code Changes +### Draft handling +- `macai/UI/Chat/ChatDraftManager.swift` + - Uses private background context for draft saves. + - Drafts store `draftImageIDs` / `draftFileIDs` only. + - Draft restore fetches IDs and loads attachments by ID. + - Draft message reads use `draftMessage` and detach the Swift string from Core Data objects. + +- `macai/Configuration/AppConstants.swift` + - Added `draftTransactionAuthor`. + +### Data model rename +- `macai/Store/macaiDataModel.xcdatamodeld/.../contents` + - `newMessage` renamed to `draftMessage` with `renamingIdentifier="newMessage"`. + +### Core Data save reliability +- `macai/Utilities/Extensions.swift` + - `saveWithRetryOnQueue` ensures save happens on context queue. + +### Thread-safety fixes (Core Data fetches) +These now use `performAndWait` on `viewContext`: +- `macai/Utilities/APIHandlers/OpenAIHandlerBase.swift` +- `macai/Utilities/APIHandlers/GeminiHandler.swift` +- `macai/Utilities/MessageParser.swift` + +These now fetch entity data **from the entity’s context queue** before using it on background threads: +- `macai/Models/ImageAttachment.swift` +- `macai/Models/DocumentAttachment.swift` + +### Attachment persistence for drafts +Attachments added via Photos picker or drag/drop are persisted immediately so the draft can restore them later: +- `macai/UI/Chat/BottomContainer/MessageInputView.swift` + +### PDF attachments (general) +- PDFs are represented in message text as `...` placeholders and stored as `DocumentEntity` records with `fileData`, `filename`, and `mimeType`. +- The UI exposes “Add PDF” and PDF previews when `pdfUploadsAllowed` is enabled for the selected API service. +- Drafts store only PDF IDs (`draftFileIDs`) and restore PDFs by ID. + +### OpenAI PDF attachments +OpenAI Responses models accept PDFs via `input_file` items (`file_data` data URL + `filename`) in the request payload: +- `macai/Utilities/APIHandlers/OpenAIResponsesHandler.swift` +- `macai/Utilities/APIHandlers/OpenAIHandlerBase.swift` +- `macai/UI/Chat/ChatLogicHandler.swift` +- `macai/UI/Chat/ChatView.swift` + +### Gemini PDF attachments +Gemini now supports PDF attachments by translating `` placeholders into `inlineData` parts (base64 + mime type), and PDF uploads are enabled in Gemini defaults/templates: +- `macai/Utilities/APIHandlers/GeminiHandler.swift` +- `macai/Configuration/AppConstants.swift` +- `macai/Utilities/APIServiceTemplateProvider.swift` +- `macai/Configuration/APIServiceTemplates.json` +- Existing Gemini services are backfilled via `DatabasePatcher.patchGeminiPdfUploadsForAPIServices` (flag: `geminiPdfUploadsPatchCompletedKey`). + +## Context Primer (Short Version) +- A **context** is a Core Data workspace with its own thread/queue. +- Objects from a context must only be accessed on that context’s queue. +- The **viewContext** is tied to the main thread (SwiftUI). +- Background contexts are used for async work (e.g., draft autosaves). +- If you read or write objects on the wrong queue, Core Data throws `_PFAssertSafeMultiThreadedAccess_impl`. + +## Testing Checklist +1) Type text → switch chat → switch back (draft restores, no crash). +2) Add image/PDF → switch chat → switch back (draft restores, no crash). +3) Send message with attachments (no crash). +4) Add attachment via drag/drop and Photos picker (draft restores). + +## Notes / Caveats +- Draft data is restored explicitly via `ChatDraftManager.draftSnapshot(...)` during `ChatView` init. +- If you introduce new Core Data fetches that run off-main, wrap them in `context.perform {}` or `performAndWait {}`. diff --git a/README.md b/README.md index c9e72eb..30cc0dc 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,14 @@ Run macai with Ollama is easy: 6. Test and enjoy! ## System requirements -macOS 14.0 and later (both Intel and Apple chips are supported) +macOS 15.0 and later (both Intel and Apple chips are supported) ## Project status Project is in the active development phase. +## Disclaimer +This software is provided "as is", without warranty of any kind. Use at your own risk. + ### Build from source #### Option 1: With Apple Developer Account (Full Features) diff --git a/docs/PRD_CoreData_V3_Migration.md b/docs/PRD_CoreData_V3_Migration.md new file mode 100755 index 0000000..a37e16f --- /dev/null +++ b/docs/PRD_CoreData_V3_Migration.md @@ -0,0 +1,164 @@ +# PRD: CoreData Model V3 Migration + +## Overview + +This document describes the CoreData model migration from v1 (base model) to v3, which was implemented to support CloudKit synchronization and improve message ordering performance. + +## Background + +The original CoreData model used an **ordered relationship** (`ordered="YES"`) for the `ChatEntity.messages` relationship. This approach had two limitations: + +1. **CloudKit Incompatibility**: CloudKit does not support ordered relationships +2. **Performance**: Ordered relationships can be slower for large datasets + +## Changes Summary + +### Model Changes (v1 → v3) + +| Entity | Attribute/Relationship | v1 (Base) | v3 | +|--------|------------------------|-----------|-----| +| ChatEntity | messages | `ordered="YES"` | Unordered (no `ordered` attribute) | +| ChatEntity | lastSequence | Not present | `Int64`, non-optional, default: 0 | +| MessageEntity | sequence | Not present | `Int64`, non-optional, default: 0 | +| ChatEntity | apiService | No inverse | Has inverse: `chats` | +| ChatEntity | persona | No inverse | Has inverse: `chats` | +| APIServiceEntity | chats | Not present | To-many relationship (inverse of apiService) | +| APIServiceEntity | defaultPersona | No inverse | Has inverse: `apiServicesAsDefault` | +| PersonaEntity | chats | Not present | To-many relationship (inverse of persona) | +| PersonaEntity | apiServicesAsDefault | Not present | To-many relationship (inverse of defaultPersona) | + +### Key Technical Decisions + +#### 1. Custom Mapping Model Required + +The change from `ordered="YES"` to unordered relationship is **not supported by lightweight migration**. This required creating a custom mapping model: + +- **File**: `V1toV3.xcmappingmodel` (created via Xcode) +- **Source Model**: `macaiDataModel.xcdatamodel` +- **Destination Model**: `macaiDataModel 3.xcdatamodel` + +#### 2. Migration Configuration + +```swift +// In PersistenceController.init() +for description in container.persistentStoreDescriptions { + description.shouldMigrateStoreAutomatically = true + description.shouldInferMappingModelAutomatically = false // Use custom mapping model +} +``` + +#### 3. Message Ordering Strategy + +Instead of relying on CoreData's ordered relationship, messages are now ordered using: + +1. **`sequence` attribute**: Each message gets a sequence number within its chat +2. **`lastSequence` attribute**: Each chat tracks the last assigned sequence number +3. **Fetch with sort descriptors**: Messages are fetched and sorted by sequence + +```swift +// In ChatEntity.messagesArray +let fetchRequest = NSFetchRequest(entityName: "MessageEntity") +fetchRequest.predicate = NSPredicate(format: "chat == %@", self) +fetchRequest.sortDescriptors = [ + NSSortDescriptor(keyPath: \MessageEntity.sequence, ascending: true), + NSSortDescriptor(keyPath: \MessageEntity.timestamp, ascending: true), +] +``` + +#### 4. Sequence Backfill + +The `DatabasePatcher.backfillMessageSequencesIfNeeded()` function populates sequence values for existing messages after migration, sorting by timestamp. + +## File Structure + +``` +macai/Store/ +├── macaiDataModel.xcdatamodeld/ +│ ├── .xccurrentversion # Points to "macaiDataModel 3.xcdatamodel" +│ ├── macaiDataModel.xcdatamodel # v1 - Base model (ordered messages) +│ ├── macaiDataModel 2.xcdatamodel # v2 - Intermediate (unused for this migration) +│ └── macaiDataModel 3.xcdatamodel # v3 - Target model (unordered + sequence) +├── V1toV3.xcmappingmodel # Custom mapping model +└── ChatStore.swift +``` + +## Backup Strategy + +### Automatic Pre-Migration Backup + +Before migration, the app automatically creates a backup of the CoreData files: + +- **Location**: `~/Library/Application Support//Backups/Before-macaiDataModel 3-/` +- **Files backed up**: `.sqlite`, `.sqlite-wal`, `.sqlite-shm` +- **One-time flag**: `CoreDataBackupBeforeV3Completed` in UserDefaults + +### Backup Discovery + +Backups are discoverable in Settings → Backup & Restore, implemented via `DatabaseBackupManager.discoverBackups()`. + +## Migration Flow + +``` +App Launch + │ + ▼ +CoreDataBackupManager.backupBeforeV3IfNeeded() + │ (Creates backup if flag not set) + ▼ +container.loadPersistentStores() + │ (Uses V1toV3.xcmappingmodel for migration) + ▼ +DatabasePatcher.applyPatches() + │ + ├── patchMissingIDs() + ├── backfillMessageSequencesIfNeeded() + └── (other patches...) + │ + ▼ +App Ready +``` + +## Testing Checklist + +- [ ] Fresh install works correctly +- [ ] Migration from v1 database preserves all chats and messages +- [ ] Messages appear in correct order after migration +- [ ] Backup is created before migration +- [ ] Backup appears in Settings → Backup & Restore +- [ ] CloudKit sync works with new model (if enabled) + +## Troubleshooting + +### Migration Fails / Empty App After Update + +1. Check console for "Failed to load persistent store" errors +2. Verify `.xccurrentversion` points to `macaiDataModel 3.xcdatamodel` +3. Ensure `V1toV3.xcmappingmodel` is included in the target +4. Verify `shouldInferMappingModelAutomatically = false` + +### Backup Not Created + +1. Check if `CoreDataBackupBeforeV3Completed` flag is already set: + ```bash + defaults read com.notfullin.macai CoreDataBackupBeforeV3Completed + ``` +2. Reset flag to test: + ```bash + defaults delete com.notfullin.macai CoreDataBackupBeforeV3Completed + ``` + +### Messages Out of Order + +1. Check if `backfillMessageSequencesIfNeeded()` ran successfully +2. Verify `MessageSequenceBackfillCompleted` flag in UserDefaults +3. Reset and re-run if needed: + ```bash + defaults delete com.notfullin.macai MessageSequenceBackfillCompleted + ``` + +## Related Files + +- `macai/macaiApp.swift` - PersistenceController, CoreDataBackupManager +- `macai/Models/Models.swift` - ChatEntity, MessageEntity with sequence logic +- `macai/Utilities/DatabasePatcher.swift` - Migration patches +- `macai/UI/Preferences/TabBackupRestoreView.swift` - Backup UI diff --git a/macai.xcodeproj/project.pbxproj b/macai.xcodeproj/project.pbxproj index f293948..100bcbc 100644 --- a/macai.xcodeproj/project.pbxproj +++ b/macai.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 4A1E16852E16DB4500E5CBA3 /* ChatInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E16832E16DB4500E5CBA3 /* ChatInputView.swift */; }; 4A1E16862E16DB4500E5CBA3 /* ChatMessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E16842E16DB4500E5CBA3 /* ChatMessagesView.swift */; }; 4A1E16872E16DB4600E5CBA3 /* ChatLogicHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E16822E16DB4500E5CBA3 /* ChatLogicHandler.swift */; }; + 9BC46E70111B408B9B189BE1 /* ChatSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70CC51F011F74CE497CC83D9 /* ChatSettingsView.swift */; }; 4A1F0A0A29CEFB7F002A139A /* MessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1F0A0929CEFB7F002A139A /* MessageCell.swift */; }; 4A1F0A0C29CF02CA002A139A /* WelcomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1F0A0B29CF02CA002A139A /* WelcomeScreen.swift */; }; 4A254A6D2CEA69EB00F66935 /* HighlighterManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A254A6C2CEA69EB00F66935 /* HighlighterManager.swift */; }; @@ -36,6 +37,20 @@ 4A27FF952DA1C4640075D730 /* ImageAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A27FF922DA1C4640075D730 /* ImageAttachment.swift */; }; 4A2842492C87014800E5C920 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 4A2842482C87014800E5C920 /* KeychainAccess */; }; 4A28424B2C87018C00E5C920 /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A28424A2C87018800E5C920 /* TokenManager.swift */; }; + C4E7B0A12E1A000100000002 /* ChatExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A000100000001 /* ChatExporter.swift */; }; + C4E7B0A12E1A000100000004 /* ChatExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A000100000003 /* ChatExporterTests.swift */; }; + C4E7B0A12E1A000100000006 /* ChatEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A000100000005 /* ChatEntityTests.swift */; }; + C4E7B0A12E1A000100000008 /* RequestMessagesTransformerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A000100000007 /* RequestMessagesTransformerTests.swift */; }; + C4E7B0A12E1A00010000000A /* MessageManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A000100000009 /* MessageManagerTests.swift */; }; + C4E7B0A12E1A00010000000C /* ExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A00010000000B /* ExtensionsTests.swift */; }; + C4E7B0A12E1A00010000000E /* DatabasePatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A00010000000D /* DatabasePatcherTests.swift */; }; + C4E7B0A12E1A000100000010 /* ModelsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A00010000000F /* ModelsCache.swift */; }; + C4E7B0A12E1A000100000014 /* AttributedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A000100000013 /* AttributedText.swift */; }; + C4E7B0A12E1A000100000016 /* AttributedTextImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A000100000015 /* AttributedTextImpl.swift */; }; + C4E7B0A12E1A000100000018 /* AttributedTextImpl+macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A000100000017 /* AttributedTextImpl+macOS.swift */; }; + C4E7B0A12E1A00010000001A /* TextSizeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A000100000019 /* TextSizeViewModel.swift */; }; + C4E7B0A12E1A00010000001C /* NSLineBreakMode+TruncationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A00010000001B /* NSLineBreakMode+TruncationMode.swift */; }; + C4E7B0A12E1A000100000012 /* ModelsCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E7B0A12E1A000100000011 /* ModelsCacheTests.swift */; }; 4A2B9E952CE1272E00C8042F /* RequestMessagesTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2B9E942CE1272E00C8042F /* RequestMessagesTransformer.swift */; }; 4A2D3CEB2D5794B10082A262 /* ThemeMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2D3CEA2D5794B00082A262 /* ThemeMode.swift */; }; 4A315DB22C8CD8410095ABD1 /* TabAIPersonasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A315DB12C8CD8410095ABD1 /* TabAIPersonasView.swift */; }; @@ -55,7 +70,6 @@ 4A4F204B2D62650800D1F57F /* FiraCode-VF.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4A4F204A2D62650800D1F57F /* FiraCode-VF.ttf */; }; 4A4F204C2D62651900D1F57F /* FiraCode-VF.ttf in CopyFiles */ = {isa = PBXBuildFile; fileRef = 4A4F204A2D62650800D1F57F /* FiraCode-VF.ttf */; }; 4A51EC6229C32AF700973D83 /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 4A51EC6129C32AF700973D83 /* Highlightr */; }; - 4A51EC6529C32B0B00973D83 /* AttributedText in Frameworks */ = {isa = PBXBuildFile; productRef = 4A51EC6429C32B0B00973D83 /* AttributedText */; }; 4A54E85B2ED6500F0068C25E /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A54E85A2ED6500F0068C25E /* CloudSyncManager.swift */; }; 4A55DCA729C264BC00A3800C /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A55DCA629C264BC00A3800C /* TableView.swift */; }; 4A5F64B829C64E21000403F7 /* ChatBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A5F64B729C64E21000403F7 /* ChatBubbleView.swift */; }; @@ -163,6 +177,7 @@ 4A1E16822E16DB4500E5CBA3 /* ChatLogicHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLogicHandler.swift; sourceTree = ""; }; 4A1E16832E16DB4500E5CBA3 /* ChatInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputView.swift; sourceTree = ""; }; 4A1E16842E16DB4500E5CBA3 /* ChatMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessagesView.swift; sourceTree = ""; }; + 70CC51F011F74CE497CC83D9 /* ChatSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSettingsView.swift; sourceTree = ""; }; 4A1F0A0929CEFB7F002A139A /* MessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCell.swift; sourceTree = ""; }; 4A1F0A0B29CF02CA002A139A /* WelcomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreen.swift; sourceTree = ""; }; 4A254A6C2CEA69EB00F66935 /* HighlighterManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlighterManager.swift; sourceTree = ""; }; @@ -174,11 +189,26 @@ 4A27FF922DA1C4640075D730 /* ImageAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageAttachment.swift; sourceTree = ""; }; 4A27FF932DA1C4640075D730 /* MessageContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageContent.swift; sourceTree = ""; }; 4A28424A2C87018800E5C920 /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = ""; }; + C4E7B0A12E1A000100000001 /* ChatExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExporter.swift; sourceTree = ""; }; + C4E7B0A12E1A000100000003 /* ChatExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExporterTests.swift; sourceTree = ""; }; + C4E7B0A12E1A000100000005 /* ChatEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatEntityTests.swift; sourceTree = ""; }; + C4E7B0A12E1A000100000007 /* RequestMessagesTransformerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestMessagesTransformerTests.swift; sourceTree = ""; }; + C4E7B0A12E1A000100000009 /* MessageManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageManagerTests.swift; sourceTree = ""; }; + C4E7B0A12E1A00010000000B /* ExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionsTests.swift; sourceTree = ""; }; + C4E7B0A12E1A00010000000D /* DatabasePatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabasePatcherTests.swift; sourceTree = ""; }; + C4E7B0A12E1A00010000000F /* ModelsCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelsCache.swift; sourceTree = ""; }; + C4E7B0A12E1A000100000013 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedText.swift"; sourceTree = ""; }; + C4E7B0A12E1A000100000015 /* AttributedTextImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedTextImpl.swift"; sourceTree = ""; }; + C4E7B0A12E1A000100000017 /* AttributedTextImpl+macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedTextImpl+macOS.swift"; sourceTree = ""; }; + C4E7B0A12E1A000100000019 /* TextSizeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextSizeViewModel.swift"; sourceTree = ""; }; + C4E7B0A12E1A00010000001B /* NSLineBreakMode+TruncationMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLineBreakMode+TruncationMode.swift"; sourceTree = ""; }; + C4E7B0A12E1A000100000011 /* ModelsCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelsCacheTests.swift; sourceTree = ""; }; 4A2B9E942CE1272E00C8042F /* RequestMessagesTransformer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestMessagesTransformer.swift; sourceTree = ""; }; 4A2D3CEA2D5794B00082A262 /* ThemeMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeMode.swift; sourceTree = ""; }; 4A315DB12C8CD8410095ABD1 /* TabAIPersonasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabAIPersonasView.swift; sourceTree = ""; }; 4A366F612EEFFA5E00128E19 /* macaiDataModel 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "macaiDataModel 2.xcdatamodel"; sourceTree = ""; }; 4A366F622EEFFA5E00128E19 /* macaiDataModel 3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "macaiDataModel 3.xcdatamodel"; sourceTree = ""; }; + 6CFDC91114AB459D8F768C41 /* macaiDataModel 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "macaiDataModel 4.xcdatamodel"; sourceTree = ""; }; 4A37B8542C2A399800F36CC4 /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; 4A399C9529BC8FEF00E98796 /* macai.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = macai.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4A399C9829BC8FEF00E98796 /* macaiApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = macaiApp.swift; sourceTree = ""; }; @@ -261,7 +291,6 @@ 4A2842492C87014800E5C920 /* KeychainAccess in Frameworks */, 4A471B862C29C56800C66926 /* SwiftMath in Frameworks */, 4A7E1BDB29C910F900E57DC2 /* Sparkle in Frameworks */, - 4A51EC6529C32B0B00973D83 /* AttributedText in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -345,6 +374,7 @@ children = ( 4AC37B622F1255C0006FD196 /* AttachmentPreviewViews.swift */, 4AC37B7D2F14000100D1A2B3 /* ChatDraftManager.swift */, + 70CC51F011F74CE497CC83D9 /* ChatSettingsView.swift */, 4A1E16832E16DB4500E5CBA3 /* ChatInputView.swift */, 4A1E16842E16DB4500E5CBA3 /* ChatMessagesView.swift */, 4A1E16822E16DB4500E5CBA3 /* ChatLogicHandler.swift */, @@ -453,6 +483,8 @@ 3EA7767A135E47FFAB9E3289 /* APIServiceTemplateProvider.swift */, 4AFEE71F2C472F29006F99FB /* APIHandlers */, 4ABDBE6529F86B5900661B2D /* MessageParser.swift */, + C4E7B0A12E1A000100000001 /* ChatExporter.swift */, + C4E7B0A12E1A00010000000F /* ModelsCache.swift */, 4A775A032C1486F3004301D3 /* Extensions.swift */, B2681BF4ED394B949A864169 /* PreviewFileHelper.swift */, 090CED78E6224CCB84778089 /* QuickLookPreviewer.swift */, @@ -484,6 +516,26 @@ name = Products; sourceTree = ""; }; + C4E7B0A12E1A00010000001D /* Vendor */ = { + isa = PBXGroup; + children = ( + C4E7B0A12E1A00010000001E /* AttributedText */, + ); + path = Vendor; + sourceTree = ""; + }; + C4E7B0A12E1A00010000001E /* AttributedText */ = { + isa = PBXGroup; + children = ( + C4E7B0A12E1A000100000013 /* AttributedText.swift */, + C4E7B0A12E1A000100000015 /* AttributedTextImpl.swift */, + C4E7B0A12E1A000100000017 /* AttributedTextImpl+macOS.swift */, + C4E7B0A12E1A000100000019 /* TextSizeViewModel.swift */, + C4E7B0A12E1A00010000001B /* NSLineBreakMode+TruncationMode.swift */, + ); + path = AttributedText; + sourceTree = ""; + }; 4A399C9729BC8FEF00E98796 /* macai */ = { isa = PBXGroup; children = ( @@ -491,6 +543,7 @@ 4A399C9C29BC8FF100E98796 /* Assets.xcassets */, 4A399CA029BC8FF100E98796 /* Preview Assets.xcassets */, 4ABDBE6429F86B3900661B2D /* Configuration */, + C4E7B0A12E1A00010000001D /* Vendor */, 4A272A7029F5D9C200F93D0D /* Utilities */, 4A272A6F29F5CE5700F93D0D /* Resources */, 4A272A6A29F5CD5E00F93D0D /* Store */, @@ -541,6 +594,13 @@ isa = PBXGroup; children = ( 4ABDBE6A29F86E8D00661B2D /* MessageParserTests.swift */, + C4E7B0A12E1A000100000003 /* ChatExporterTests.swift */, + C4E7B0A12E1A000100000005 /* ChatEntityTests.swift */, + C4E7B0A12E1A000100000007 /* RequestMessagesTransformerTests.swift */, + C4E7B0A12E1A000100000009 /* MessageManagerTests.swift */, + C4E7B0A12E1A00010000000B /* ExtensionsTests.swift */, + C4E7B0A12E1A00010000000D /* DatabasePatcherTests.swift */, + C4E7B0A12E1A000100000011 /* ModelsCacheTests.swift */, 1D1475CF2DC44692A9E47B9A /* GeminiHandlerTests.swift */, 7C2B9A4A2F1E4B1F00D1A111 /* DatabaseMigrationTests.swift */, BEFF573F6F9A453BAFF2C3A5 /* PDFHandlingTests.swift */, @@ -586,7 +646,6 @@ name = macai; packageProductDependencies = ( 4A51EC6129C32AF700973D83 /* Highlightr */, - 4A51EC6429C32B0B00973D83 /* AttributedText */, 4A7E1BDA29C910F900E57DC2 /* Sparkle */, 4A471B852C29C56800C66926 /* SwiftMath */, 4A2842482C87014800E5C920 /* KeychainAccess */, @@ -665,7 +724,6 @@ mainGroup = 4A399C8C29BC8FEF00E98796; packageReferences = ( 4A51EC6029C32AF600973D83 /* XCRemoteSwiftPackageReference "Highlightr" */, - 4A51EC6329C32B0B00973D83 /* XCRemoteSwiftPackageReference "AttributedText" */, 4A7E1BD929C910F800E57DC2 /* XCRemoteSwiftPackageReference "Sparkle" */, 4A471B842C29C56800C66926 /* XCRemoteSwiftPackageReference "SwiftMath" */, 4A2842472C87014800E5C920 /* XCRemoteSwiftPackageReference "KeychainAccess" */, @@ -739,6 +797,13 @@ 4A885C522CEA3B130018BBED /* HTMLPreviewView.swift in Sources */, 4A55DCA729C264BC00A3800C /* TableView.swift in Sources */, 4A28424B2C87018C00E5C920 /* TokenManager.swift in Sources */, + C4E7B0A12E1A000100000002 /* ChatExporter.swift in Sources */, + C4E7B0A12E1A000100000010 /* ModelsCache.swift in Sources */, + C4E7B0A12E1A000100000014 /* AttributedText.swift in Sources */, + C4E7B0A12E1A000100000016 /* AttributedTextImpl.swift in Sources */, + C4E7B0A12E1A000100000018 /* AttributedTextImpl+macOS.swift in Sources */, + C4E7B0A12E1A00010000001A /* TextSizeViewModel.swift in Sources */, + C4E7B0A12E1A00010000001C /* NSLineBreakMode+TruncationMode.swift in Sources */, 0BA0FF405FC14D3FABF82621 /* APIServiceTemplateProvider.swift in Sources */, 4ACE2FB82D61656000DEEA6D /* SwipeModifier.swift in Sources */, 4A0BCBBC2D5199140033AB96 /* ButtonWithStatusIndicator.swift in Sources */, @@ -800,6 +865,7 @@ 4A1E16852E16DB4500E5CBA3 /* ChatInputView.swift in Sources */, 4A1E16862E16DB4500E5CBA3 /* ChatMessagesView.swift in Sources */, 4A1E16872E16DB4600E5CBA3 /* ChatLogicHandler.swift in Sources */, + 9BC46E70111B408B9B189BE1 /* ChatSettingsView.swift in Sources */, 4AFEE7252C472FB1006F99FB /* OllamaHandler.swift in Sources */, 4AE8691B2D5696A300E3B3AC /* GeminiHandler.swift in Sources */, ); @@ -810,6 +876,13 @@ buildActionMask = 2147483647; files = ( 4ABDBE6B29F86E8D00661B2D /* MessageParserTests.swift in Sources */, + C4E7B0A12E1A000100000004 /* ChatExporterTests.swift in Sources */, + C4E7B0A12E1A000100000006 /* ChatEntityTests.swift in Sources */, + C4E7B0A12E1A000100000008 /* RequestMessagesTransformerTests.swift in Sources */, + C4E7B0A12E1A00010000000A /* MessageManagerTests.swift in Sources */, + C4E7B0A12E1A00010000000C /* ExtensionsTests.swift in Sources */, + C4E7B0A12E1A00010000000E /* DatabasePatcherTests.swift in Sources */, + C4E7B0A12E1A000100000012 /* ModelsCacheTests.swift in Sources */, 4F432A6BC5E64A3390D5F2C5 /* PDFHandlingTests.swift in Sources */, 7CEEFA33B4C841A2AC2BFBA2 /* APIServiceTemplateProviderTests.swift in Sources */, 0E6BF47F5D74490F95504FF6 /* GeminiHandlerTests.swift in Sources */, @@ -969,7 +1042,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2.4.2; + CURRENT_PROJECT_VERSION = 3.0.0; DEVELOPMENT_TEAM = ZRB8WDV435; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -987,8 +1060,8 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.4.2; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 3.0.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = notfullin.com.macai; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1012,7 +1085,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2.4.2; + CURRENT_PROJECT_VERSION = 3.0.0; DEVELOPMENT_TEAM = ZRB8WDV435; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -1030,8 +1103,8 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.4.2; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 3.0.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = notfullin.com.macai; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1054,7 +1127,7 @@ DEVELOPMENT_TEAM = ZRB8WDV435; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = notfullin.com.macaiTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1077,7 +1150,7 @@ DEVELOPMENT_TEAM = ZRB8WDV435; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = notfullin.com.macaiTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1200,14 +1273,6 @@ kind = branch; }; }; - 4A51EC6329C32B0B00973D83 /* XCRemoteSwiftPackageReference "AttributedText" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/gonzalezreal/AttributedText"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; 4A7E1BD929C910F800E57DC2 /* XCRemoteSwiftPackageReference "Sparkle" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sparkle-project/Sparkle"; @@ -1234,11 +1299,6 @@ package = 4A51EC6029C32AF600973D83 /* XCRemoteSwiftPackageReference "Highlightr" */; productName = Highlightr; }; - 4A51EC6429C32B0B00973D83 /* AttributedText */ = { - isa = XCSwiftPackageProductDependency; - package = 4A51EC6329C32B0B00973D83 /* XCRemoteSwiftPackageReference "AttributedText" */; - productName = AttributedText; - }; 4A7E1BDA29C910F900E57DC2 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = 4A7E1BD929C910F800E57DC2 /* XCRemoteSwiftPackageReference "Sparkle" */; @@ -1253,8 +1313,9 @@ 4A751D2429CBA70B006B983B /* macaiDataModel.xcdatamodel */, 4A366F612EEFFA5E00128E19 /* macaiDataModel 2.xcdatamodel */, 4A366F622EEFFA5E00128E19 /* macaiDataModel 3.xcdatamodel */, + 6CFDC91114AB459D8F768C41 /* macaiDataModel 4.xcdatamodel */, ); - currentVersion = 4A366F622EEFFA5E00128E19 /* macaiDataModel 3.xcdatamodel */; + currentVersion = 6CFDC91114AB459D8F768C41 /* macaiDataModel 4.xcdatamodel */; path = macaiDataModel.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/macai.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macai.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b3942f2..d1811bc 100644 --- a/macai.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macai.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "d070fef2f64af06e20606fc1c2f79b65b560cf76e83eda752a69e276d4467416", + "originHash" : "4e3c5261ae9082c0db29861ac762c2ff7c0e2a77139558e5f5c15146a81cf5a0", "pins" : [ - { - "identity" : "attributedtext", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/AttributedText", - "state" : { - "revision" : "0e811cb14de367bbd58a94d12259687e5ee08bea", - "version" : "1.0.1" - } - }, { "identity" : "highlightr", "kind" : "remoteSourceControl", diff --git a/macai/Configuration/AppConstants.swift b/macai/Configuration/AppConstants.swift index 75d96a0..7bb4e4f 100644 --- a/macai/Configuration/AppConstants.swift +++ b/macai/Configuration/AppConstants.swift @@ -55,6 +55,9 @@ struct AppConstants { static let openRouterUploadsPatchCompletedKey = "OpenRouterUploadsPatchCompleted" static let apiServiceMigrationCompletedKey = "APIServiceMigrationCompleted" static let defaultApiServiceMigrationCompletedKey = "DefaultAPIServiceMigrationCompleted" + static let assistantSettingsMigrationCompletedKey = "AssistantSettingsMigrationCompletedV2" + static let assistantSettingsMigrationNoticePendingKey = "AssistantSettingsMigrationNoticePending" + static let selectedPreferencesTabKey = "SelectedPreferencesTab" static let draftTransactionAuthor = "Drafts" static let entityIDBackfillCompletedKey = "EntityIDBackfillCompleted" static let messageSequenceBackfillCompletedKey = "MessageSequenceBackfillCompleted" @@ -321,6 +324,8 @@ struct AppConstants { "openai-responses", "chatgpt", "ollama", "claude", "xai", "gemini", "perplexity", "deepseek", "openrouter", ] static let newChatNotification = Notification.Name("newChatNotification") + static let selectAssistantNotification = Notification.Name("selectAssistantNotification") + static let exportChatNotification = Notification.Name("exportChatNotification") static let largeMessageSymbolsThreshold = 25000 static let thumbnailSize: CGFloat = 300 static let maxHighlightableTextLength = 100000 diff --git a/macai/Info.plist b/macai/Info.plist index 6cf848a..e5e00a9 100644 --- a/macai/Info.plist +++ b/macai/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsLocalNetworking + + ATSApplicationFontsPath fonts CloudKitContainerIdentifier diff --git a/macai/Models/Models.swift b/macai/Models/Models.swift index 177c3e7..9512f22 100644 --- a/macai/Models/Models.swift +++ b/macai/Models/Models.swift @@ -15,6 +15,17 @@ public class ChatEntity: NSManagedObject, Identifiable { @NSManaged public var requestMessages: [[String: String]] @NSManaged public var newChat: Bool @NSManaged public var temperature: Double + /// Whether `temperature` holds a per-chat override. The legacy `temperature` + /// column contains stale values (old versions wrote 0/1 unconditionally), + /// so a companion flag is required to tell an override from leftovers. + @NSManaged public var temperatureOverridden: Bool + /// Per-chat context size override; 0 means "inherit from the assistant". + @NSManaged public var contextSize: Int16 + /// Per-chat image generation override: 0 = inherit, 1 = enabled, 2 = disabled. + @NSManaged public var imageGenerationMode: Int16 + @NSManaged public var apiServiceOverridden: Bool + @NSManaged public var modelOverridden: Bool + @NSManaged public var systemMessageOverridden: Bool @NSManaged public var top_p: Double @NSManaged public var behavior: String? @NSManaged public var draftMessage: String? @@ -302,23 +313,154 @@ extension ChatEntity { } } +// Effective settings: the chat's own values win; anything the chat does not +// define is inherited from its assistant (persona). The Chat Settings panel +// writes to the chat only — assistants are edited in Settings → AI Assistants. +extension ChatEntity { + var effectiveApiService: APIServiceEntity? { + if apiServiceOverridden { + return apiService + } + return persona?.apiService ?? apiService + } + + var effectiveModel: String { + let chatModel = gptModel.trimmingCharacters(in: .whitespacesAndNewlines) + if modelOverridden, chatModel.isEmpty == false { + return chatModel + } + let personaModel = persona?.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if personaModel.isEmpty == false { + return personaModel + } + if chatModel.isEmpty == false { + return chatModel + } + return AppConstants.defaultModel(for: effectiveApiService?.type) + } + + var effectiveSystemMessage: String { + if systemMessageOverridden || persona == nil { + return systemMessage + } + return persona?.systemMessage ?? systemMessage + } + + var effectiveTemperature: Float { + if temperatureOverridden { + return Float(min(max(temperature, 0), 1)) + } + return persona?.temperature ?? AppConstants.defaultTemperatureForChat + } + + var effectiveContextSize: Int { + if contextSize > 0 { + return Int(contextSize) + } + let personaSize = Int(persona?.contextSize ?? 0) + if personaSize > 0 { + return personaSize + } + return Int(AppConstants.chatGptContextSize) + } + + var shouldUseStreamResponse: Bool { + persona?.useStreamResponse ?? true + } + + var shouldGenerateChatNames: Bool { + persona?.generateChatNames ?? true + } + + var imageGenerationEnabled: Bool { + switch imageGenerationMode { + case 1: return true + case 2: return false + default: return persona?.imageGenerationEnabled ?? false + } + } + + var supportsImageGeneration: Bool { + (effectiveApiService?.imageGenerationSupported ?? false) && imageGenerationEnabled + } + + /// Drops every per-chat override so the chat follows its assistant again, + /// and re-snapshots the service/model/system message from the assistant. + func resetToAssistantDefaults() { + temperatureOverridden = false + contextSize = 0 + imageGenerationMode = 0 + apiServiceOverridden = false + modelOverridden = false + systemMessageOverridden = false + + guard let persona else { return } + if let personaService = persona.apiService { + apiService = personaService + } + let personaModel = persona.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + gptModel = personaModel.isEmpty + ? AppConstants.defaultModel(for: effectiveApiService?.type) + : personaModel + systemMessage = persona.systemMessage ?? systemMessage + } + + var allowsImageUploads: Bool { + effectiveApiService?.imageUploadsAllowed ?? false + } + + var allowsPdfUploads: Bool { + effectiveApiService?.pdfUploadsAllowed ?? false + } +} + extension APIServiceEntity: NSCopying { public func copy(with zone: NSZone? = nil) -> Any { let copy = APIServiceEntity(context: self.managedObjectContext!) copy.name = self.name copy.type = self.type copy.url = self.url - copy.model = self.model - copy.contextSize = self.contextSize - copy.useStreamResponse = self.useStreamResponse - copy.generateChatNames = self.generateChatNames copy.imageUploadsAllowed = self.imageUploadsAllowed copy.pdfUploadsAllowed = self.pdfUploadsAllowed copy.imageGenerationSupported = self.imageGenerationSupported - copy.defaultPersona = self.defaultPersona + // Legacy migration bridge only; API Services no longer own a default assistant. + copy.defaultPersona = nil copy.isDefault = false copy.id = UUID() copy.tokenIdentifier = UUID().uuidString return copy } } + +extension PersonaEntity { + /// Resolves the assistant used for a new chat. A global default is preferred + /// for ordinary chats; service-specific shortcuts fall back to the first + /// assistant bound to that service. + static func preferredAssistant( + in context: NSManagedObjectContext, + for service: APIServiceEntity? = nil + ) throws -> PersonaEntity? { + let request = NSFetchRequest(entityName: "PersonaEntity") + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \PersonaEntity.order, ascending: true), + NSSortDescriptor(keyPath: \PersonaEntity.addedDate, ascending: true), + ] + if let service { + request.predicate = NSPredicate(format: "apiService == %@", service) + } else { + request.predicate = NSPredicate(format: "apiService != nil") + } + + let assistants = try context.fetch(request) + return assistants.first(where: { $0.isDefault }) ?? assistants.first + } + + static func setDefault(_ selected: PersonaEntity, in context: NSManagedObjectContext) throws { + let request = NSFetchRequest(entityName: "PersonaEntity") + request.predicate = NSPredicate(format: "isDefault == YES") + for assistant in try context.fetch(request) where assistant !== selected { + assistant.isDefault = false + } + selected.isDefault = true + } +} diff --git a/macai/Models/RequestMessagesTransformer.swift b/macai/Models/RequestMessagesTransformer.swift index 56de75e..d52debb 100644 --- a/macai/Models/RequestMessagesTransformer.swift +++ b/macai/Models/RequestMessagesTransformer.swift @@ -31,6 +31,9 @@ class RequestMessagesTransformer: NSSecureUnarchiveFromDataTransformer { guard let data = value as? Data else { return nil } - return try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSArray.self, from: data) as? [[String: String]] + // The archive is an array of dictionaries of strings, so all three + // classes must be allowed for secure unarchiving to succeed. + let allowedClasses = [NSArray.self, NSDictionary.self, NSString.self] + return try? NSKeyedUnarchiver.unarchivedObject(ofClasses: allowedClasses, from: data) as? [[String: String]] } } diff --git a/macai/Store/ChatStore.swift b/macai/Store/ChatStore.swift index 9f5292e..0ded4d2 100644 --- a/macai/Store/ChatStore.swift +++ b/macai/Store/ChatStore.swift @@ -57,6 +57,10 @@ class ChatStore: ObservableObject { func saveToCoreData(chats: [Chat], completion: @escaping (Result) -> Void) { do { + let defaultAssistant: PersonaEntity? = { + try? PersonaEntity.preferredAssistant(in: viewContext) + }() + let defaultApiService: APIServiceEntity? = { let request = NSFetchRequest(entityName: "APIServiceEntity") request.predicate = NSPredicate(format: "isDefault == YES") @@ -125,7 +129,13 @@ class ChatStore: ObservableObject { personaFetch.predicate = NSPredicate(format: "name == %@", personaName) if let existingPersona = try viewContext.fetch(personaFetch).first { chatEntity.persona = existingPersona + if let assistantService = existingPersona.apiService { + chatEntity.apiService = assistantService + } } + } else if let defaultAssistant { + chatEntity.persona = defaultAssistant + chatEntity.apiService = defaultAssistant.apiService } var nextSequence: Int64 = 0 @@ -151,11 +161,11 @@ class ChatStore: ObservableObject { } } + try viewContext.save() + DispatchQueue.main.async { completion(.success(chats.count)) } - - try viewContext.save() } catch { DispatchQueue.main.async { @@ -244,9 +254,8 @@ class ChatStore: ObservableObject { let oldChats = try decoder.decode([Chat].self, from: data) saveToCoreData(chats: oldChats) { result in - print("State saved") if case .failure(let error) = result { - fatalError(error.localizedDescription) + print("Error saving migrated chats: \(error.localizedDescription)") } } diff --git a/macai/Store/V1ToV3Migration.swift b/macai/Store/V1ToV3Migration.swift index 1d85389..eefe4e0 100644 --- a/macai/Store/V1ToV3Migration.swift +++ b/macai/Store/V1ToV3Migration.swift @@ -508,7 +508,14 @@ struct MigrationDataExport { let personaPK = data["ZDEFAULTPERSONA"] as? Int64, let service = apiServiceMap[pk], let persona = personaMap[personaPK] { - service.setValue(persona, forKey: "defaultPersona") + if service.entity.relationshipsByName["legacyDefaultPersona"] != nil { + service.setValue(persona, forKey: "legacyDefaultPersona") + } else if service.entity.relationshipsByName["defaultPersona"] != nil { + service.setValue(persona, forKey: "defaultPersona") + } + if persona.entity.attributesByName["isDefault"] != nil { + persona.setValue(true, forKey: "isDefault") + } } } diff --git a/macai/Store/macaiDataModel.xcdatamodeld/.xccurrentversion b/macai/Store/macaiDataModel.xcdatamodeld/.xccurrentversion index 8ebd38b..1c63443 100644 --- a/macai/Store/macaiDataModel.xcdatamodeld/.xccurrentversion +++ b/macai/Store/macaiDataModel.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - macaiDataModel 3.xcdatamodel + macaiDataModel 4.xcdatamodel diff --git a/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents b/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents new file mode 100644 index 0000000..22ce4f3 --- /dev/null +++ b/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift index 5593b95..e64e76f 100644 --- a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift +++ b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift @@ -7,6 +7,12 @@ import SwiftUI struct ChatBottomContainerView: View { + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \PersonaEntity.order, ascending: true)], + predicate: NSPredicate(format: "apiService != nil"), + animation: .default + ) + private var assistants: FetchedResults @ObservedObject var chat: ChatEntity @Binding var newMessage: String @Binding var isExpanded: Bool @@ -24,6 +30,32 @@ struct ChatBottomContainerView: View { var onStopInference: () -> Void var onCancelEdit: () -> Void var onExpandedStateChange: ((Bool) -> Void)? // Add this line + @State private var isMentionPickerPresented = false + @State private var selectedMentionIndex = 0 + + private var mentionQuery: String { + guard newMessage.first == "@" else { return "" } + return String(newMessage.dropFirst()) + } + + private var filteredMentionAssistants: [PersonaEntity] { + guard !mentionQuery.isEmpty else { return Array(assistants) } + return assistants.filter { + ($0.name ?? "").localizedCaseInsensitiveContains(mentionQuery) + } + } + + private var mentionCandidates: [PersonaEntity] { + filteredMentionAssistants + } + + private var mentionPickerLeadingOffset: CGFloat { + imageUploadsAllowed || pdfUploadsAllowed ? 48 : 8 + } + + private var mentionPickerHeight: CGFloat { + AssistantMentionPicker.totalHeight(for: mentionCandidates.count) + } init( chat: ChatEntity, @@ -89,16 +121,34 @@ struct ChatBottomContainerView: View { imageUploadsAllowed: imageUploadsAllowed, pdfUploadsAllowed: pdfUploadsAllowed, imageGenerationSupported: imageGenerationSupported, - onEnter: onSendMessage, + onEnter: handleEnter, onAddImage: onAddImage, onAddFile: onAddFile, onStopInference: onStopInference, - onCancelEdit: onCancelEdit + onCancelEdit: onCancelEdit, + onMoveUp: isMentionPickerPresented ? moveMentionSelectionUp : nil, + onMoveDown: isMentionPickerPresented ? moveMentionSelectionDown : nil, + onDismissSuggestions: isMentionPickerPresented ? dismissMentionPicker : nil ) .textFieldStyle(RoundedBorderTextFieldStyle()) .multilineTextAlignment(.leading) .lineLimit(nil) .padding() + .overlay(alignment: .topLeading) { + if isMentionPickerPresented { + AssistantMentionPicker( + assistants: mentionCandidates, + selectedIndex: selectedMentionIndex, + onSelect: selectMentionAssistant + ) + .offset( + x: mentionPickerLeadingOffset, + y: -(mentionPickerHeight + 6) + ) + .transition(.opacity.combined(with: .scale(scale: 0.98, anchor: .bottomLeading))) + } + } + .zIndex(1) } .border(width: 1, edges: [.top], color: Color(NSColor.windowBackgroundColor).opacity(0.8)) } @@ -120,6 +170,168 @@ struct ChatBottomContainerView: View { .padding(.horizontal) .offset(y: -16) } + .onChange(of: newMessage) { _, value in + updateMentionPresentation(for: value) + } + .animation(.easeOut(duration: 0.12), value: isMentionPickerPresented) + + } + + private func updateMentionPresentation(for value: String) { + let query = value.dropFirst() + isMentionPickerPresented = !isEditingSystemMessage + && value.first == "@" + && !query.contains(where: { $0.isWhitespace }) + selectedMentionIndex = 0 + } + + private func handleEnter() { + if isMentionPickerPresented { + if mentionCandidates.indices.contains(selectedMentionIndex) { + let assistant = mentionCandidates[selectedMentionIndex] + selectMentionAssistant(assistant) + } + return + } + onSendMessage() + } + + private func selectMentionAssistant(_ assistant: PersonaEntity) { + PersonaSelectorView.updatePersonaAndSystemMessage(assistant, for: chat) + newMessage = "" + isMentionPickerPresented = false + } + private func moveMentionSelectionUp() { + guard isMentionPickerPresented, !mentionCandidates.isEmpty else { return } + selectedMentionIndex = selectedMentionIndex == 0 + ? mentionCandidates.count - 1 + : selectedMentionIndex - 1 + } + + private func moveMentionSelectionDown() { + guard isMentionPickerPresented, !mentionCandidates.isEmpty else { return } + selectedMentionIndex = (selectedMentionIndex + 1) % mentionCandidates.count + } + + private func dismissMentionPicker() { + guard isMentionPickerPresented else { + if isEditingSystemMessage { onCancelEdit() } + return + } + isMentionPickerPresented = false + } +} + +private struct AssistantMentionPicker: View { + let assistants: [PersonaEntity] + let selectedIndex: Int + let onSelect: (PersonaEntity) -> Void + + static func totalHeight(for count: Int) -> CGFloat { + (count == 0 ? 84 : listHeight(for: count)) + 12 + } + + private static func listHeight(for count: Int) -> CGFloat { + min(max(CGFloat(count) * 44 + 6, 132), 300) + } + + private var listHeight: CGFloat { + Self.listHeight(for: assistants.count) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if assistants.isEmpty { + ContentUnavailableView( + "No Assistants Found", + systemImage: "person.crop.circle.badge.questionmark" + ) + .frame(width: 232, height: 84) + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 3) { + ForEach(Array(assistants.enumerated()), id: \.element.objectID) { index, assistant in + AssistantMentionRow( + assistant: assistant, + isSelected: index == selectedIndex + ) { + onSelect(assistant) + } + .id(assistant.objectID) + } + } + .padding(3) + } + .frame(height: listHeight) + .onChange(of: selectedIndex) { _, index in + guard assistants.indices.contains(index) else { return } + withAnimation(.easeOut(duration: 0.1)) { + proxy.scrollTo(assistants[index].objectID, anchor: .center) + } + } + } + } + } + .padding(6) + .frame(width: 280) + .background(.regularMaterial, in: .rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.18), radius: 12, y: 5) + } +} + +private struct AssistantMentionRow: View { + let assistant: PersonaEntity + let isSelected: Bool + let action: () -> Void + + private var details: String { + let service = assistant.apiService + let model = assistant.model?.trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveModel = model?.isEmpty == false + ? (model ?? "") + : AppConstants.defaultModel(for: service?.type) + let serviceName = service?.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return serviceName.isEmpty ? effectiveModel : "\(effectiveModel) • \(serviceName)" + } + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Circle() + .fill(Color(hex: assistant.color ?? AppConstants.defaultPersonaColor) ?? .accentColor) + .frame(width: 10, height: 10) + + VStack(alignment: .leading, spacing: 1) { + Text(assistant.name ?? "Untitled Assistant") + .lineLimit(1) + Text(details) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 4) + + if isSelected { + Image(systemName: "return") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .contentShape(.rect) + .padding(.horizontal, 7) + .padding(.vertical, 5) + .background( + Color.accentColor.opacity(isSelected ? 0.16 : 0), + in: .rect(cornerRadius: 6) + ) + } + .buttonStyle(.plain) } } diff --git a/macai/UI/Chat/BottomContainer/MessageInputView.swift b/macai/UI/Chat/BottomContainer/MessageInputView.swift index 2dfcde3..498d7c5 100644 --- a/macai/UI/Chat/BottomContainer/MessageInputView.swift +++ b/macai/UI/Chat/BottomContainer/MessageInputView.swift @@ -24,6 +24,9 @@ struct MessageInputView: View { var onAddFile: () -> Void var onStopInference: () -> Void var onCancelEdit: () -> Void + var onMoveUp: (() -> Void)? + var onMoveDown: (() -> Void)? + var onDismissSuggestions: (() -> Void)? private let frontReturnKeyType: MacaiTextField.ReturnKeyType = .next @State var isFocused: Focus? @@ -115,6 +118,9 @@ struct MessageInputView: View { onAddFile: @escaping () -> Void, onStopInference: @escaping () -> Void, onCancelEdit: @escaping () -> Void = {}, + onMoveUp: (() -> Void)? = nil, + onMoveDown: (() -> Void)? = nil, + onDismissSuggestions: (() -> Void)? = nil, inputPlaceholderText: String = "Type your prompt here", cornerRadius: Double = 20.0 ) { @@ -131,6 +137,9 @@ struct MessageInputView: View { self.onAddFile = onAddFile self.onStopInference = onStopInference self.onCancelEdit = onCancelEdit + self.onMoveUp = onMoveUp + self.onMoveDown = onMoveDown + self.onDismissSuggestions = onDismissSuggestions self.inputPlaceholderText = inputPlaceholderText self.cornerRadius = cornerRadius } @@ -240,7 +249,12 @@ struct MessageInputView: View { fontSize: effectiveFontSize, minHeight: initialInputSize, maxHeight: maxInputHeight, - onEscape: isEditingSystemMessage ? onCancelEdit : nil, + onEscape: onDismissSuggestions ?? (isEditingSystemMessage ? onCancelEdit : nil), + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, + onPasteImage: imageUploadsAllowed ? addPastedImage : nil, + onPastePDF: pdfUploadsAllowed ? addPastedPDF : nil, + handlesPasteAttachments: true, onCommit: { guard !isInferenceInProgress else { return } onEnter() @@ -288,10 +302,10 @@ struct MessageInputView: View { .onAppear { syncAttachmentOrder() } - .onChange(of: attachedImages.map { $0.id }) { _ in + .onChange(of: attachedImages.map { $0.id }) { _, _ in syncAttachmentOrder() } - .onChange(of: attachedFiles.map { $0.id }) { _ in + .onChange(of: attachedFiles.map { $0.id }) { _, _ in syncAttachmentOrder() } .onAppear { @@ -300,7 +314,7 @@ struct MessageInputView: View { } } .photosPicker(isPresented: $isShowingPhotosPicker, selection: $photoPickerItems, matching: .images, photoLibrary: .shared()) - .onChange(of: photoPickerItems) { newItems in + .onChange(of: photoPickerItems) { _, newItems in guard imageUploadsAllowed, !newItems.isEmpty else { return } Task { var newAttachments: [ImageAttachment] = [] @@ -326,6 +340,36 @@ struct MessageInputView: View { } } + private func addPastedImage(_ image: NSImage) { + let attachment = ImageAttachment(image: image) + attachment.saveToEntity(image: image, context: viewContext) + withAnimation { + attachedImages.append(attachment) + } + } + + private func addPastedPDF(_ payload: PastedPDF) { + let url: URL? + if let sourceURL = payload.url { + url = sourceURL + } else if let data = payload.data { + url = PreviewFileHelper.writeTemporaryFile( + data: data, + filename: payload.filename, + defaultExtension: "pdf", + id: UUID() + ) + } else { + url = nil + } + + guard let url else { return } + let attachment = DocumentAttachment(url: url, context: viewContext) + withAnimation { + attachedFiles.append(attachment) + } + } + private func handleDrop(providers: [NSItemProvider]) -> Bool { var didHandleDrop = false diff --git a/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift b/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift index 823580f..714219e 100644 --- a/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift +++ b/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift @@ -73,9 +73,24 @@ struct PersonaSelectorView: View { @ObservedObject var chat: ChatEntity @Environment(\.colorScheme) var colorScheme - private func updatePersonaAndSystemMessage(to persona: PersonaEntity?) { + static func updatePersonaAndSystemMessage(_ persona: PersonaEntity, for chat: ChatEntity) { chat.persona = persona - chat.systemMessage = persona?.systemMessage ?? AppConstants.chatGptSystemMessage + chat.systemMessage = persona.systemMessage ?? AppConstants.chatGptSystemMessage + // Switching assistants drops the chat's own overrides: the chat starts + // following the new assistant's settings. + chat.temperatureOverridden = false + chat.contextSize = 0 + chat.imageGenerationMode = 0 + chat.apiServiceOverridden = false + chat.modelOverridden = false + chat.systemMessageOverridden = false + if let service = persona.apiService { + chat.apiService = service + let personaModel = persona.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + chat.gptModel = personaModel.isEmpty ? AppConstants.defaultModel(for: service.type) : personaModel + } else if chat.gptModel.isEmpty { + chat.gptModel = AppConstants.defaultPrimaryModel + } chat.objectWillChange.send() if let context = chat.managedObjectContext { @@ -91,7 +106,7 @@ struct PersonaSelectorView: View { PersonaChipView(persona: persona, isSelected: chat.persona == persona) .onTapGesture { withAnimation(.easeOut(duration: 0.2)) { - updatePersonaAndSystemMessage(to: persona) + Self.updatePersonaAndSystemMessage(persona, for: chat) } } .id(persona) diff --git a/macai/UI/Chat/BubbleView/MessageContentView.swift b/macai/UI/Chat/BubbleView/MessageContentView.swift index d9d6a43..8d881bf 100644 --- a/macai/UI/Chat/BubbleView/MessageContentView.swift +++ b/macai/UI/Chat/BubbleView/MessageContentView.swift @@ -6,7 +6,6 @@ // import AppKit -import AttributedText import CoreData import SwiftUI diff --git a/macai/UI/Chat/ChatInputView.swift b/macai/UI/Chat/ChatInputView.swift index 76e39d2..3a02260 100644 --- a/macai/UI/Chat/ChatInputView.swift +++ b/macai/UI/Chat/ChatInputView.swift @@ -43,6 +43,7 @@ struct ChatInputView: View { onSendMessage: { if editSystemMessage { chat.systemMessage = newMessage + chat.systemMessageOverridden = true newMessage = "" editSystemMessage = false store.saveInCoreData() diff --git a/macai/UI/Chat/ChatLogicHandler.swift b/macai/UI/Chat/ChatLogicHandler.swift index 299d5ff..28e7baa 100644 --- a/macai/UI/Chat/ChatLogicHandler.swift +++ b/macai/UI/Chat/ChatLogicHandler.swift @@ -27,14 +27,15 @@ class ChatLogicHandler: ObservableObject { self.store = store } - func sendMessage(messageText: String, attachedImages: [ImageAttachment], attachedFiles: [DocumentAttachment]) { - guard !chat.waitingForResponse, !isStreaming else { return } + @discardableResult + func sendMessage(messageText: String, attachedImages: [ImageAttachment], attachedFiles: [DocumentAttachment]) -> Bool { + guard !chat.waitingForResponse, !isStreaming else { return false } guard chatViewModel.canSendMessage else { currentError = ErrorMessage( - type: .noApiService("No API service selected. Select the API service to send your first message"), + type: .noApiService("No API service selected. Choose an assistant with an API service to send your first message"), timestamp: Date() ) - return + return false } resetError() @@ -47,7 +48,7 @@ class ChatLogicHandler: ObservableObject { ? "One or more attachments failed to load. Remove them or try again." : "Attachments are still loading. Please wait until they finish." currentError = ErrorMessage(type: .attachmentNotReady(message), timestamp: Date()) - return + return false } var messageContents: [MessageContent] = [] @@ -82,15 +83,16 @@ class ChatLogicHandler: ObservableObject { saveNewMessageInStore(with: messageBody) userIsScrolling = false - if chat.apiService?.useStreamResponse ?? false { + if chat.shouldUseStreamResponse { sendStreamMessage(messageBody) } else { sendRegularMessage(messageBody) } + return true } func selectAndAddImages(completion: @escaping ([ImageAttachment]) -> Void) { - guard chat.apiService?.imageUploadsAllowed == true else { return } + guard chat.allowsImageUploads else { return } let panel = NSOpenPanel() panel.allowsMultipleSelection = true @@ -175,7 +177,7 @@ class ChatLogicHandler: ObservableObject { Task { @MainActor in chatViewModel.sendMessageStream( messageBody, - contextSize: Int(chat.apiService?.contextSize ?? Int16(AppConstants.chatGptContextSize)) + contextSize: chat.effectiveContextSize ) { result in DispatchQueue.main.async { switch result { @@ -199,7 +201,7 @@ class ChatLogicHandler: ObservableObject { chat.waitingForResponse = true chatViewModel.sendMessage( messageBody, - contextSize: Int(chat.apiService?.contextSize ?? Int16(AppConstants.chatGptContextSize)) + contextSize: chat.effectiveContextSize ) { result in DispatchQueue.main.async { switch result { @@ -271,6 +273,6 @@ class ChatLogicHandler: ObservableObject { } private func supportsPDFUploads() -> Bool { - return chat.apiService?.pdfUploadsAllowed == true + return chat.allowsPdfUploads } } diff --git a/macai/UI/Chat/ChatMessagesView.swift b/macai/UI/Chat/ChatMessagesView.swift index 8d05602..b128d4c 100644 --- a/macai/UI/Chat/ChatMessagesView.swift +++ b/macai/UI/Chat/ChatMessagesView.swift @@ -19,156 +19,43 @@ struct ChatMessagesView: View { @Binding var searchText: String let reasoningDurations: [NSManagedObjectID: TimeInterval] let activeReasoningMessageID: NSManagedObjectID? - @State private var scrollDebounceWorkItem: DispatchWorkItem? - @State private var codeBlocksRendered = false - @State private var pendingCodeBlocks = 0 - @State private var isInitialLoad = true - + + private static let bottomAnchorID = "bottomAnchor" + /// How close (in points) the viewport bottom must be to the content bottom + /// to still count as "at the bottom". Generous enough to survive rounding + /// and bounce, small enough that a deliberate scroll releases the pin. + private static let bottomTolerance: CGFloat = 40 + var backgroundColor = Color(NSColor.controlBackgroundColor) private var shouldShowWaitingBubble: Bool { chat.waitingForResponse && (chat.lastMessage?.own ?? true) } - + var body: some View { ScrollView { ScrollViewReader { scrollView in - VStack { - SystemMessageBubbleView( - message: chat.systemMessage, - color: chat.persona?.color, - newMessage: $newMessage, - editSystemMessage: $editSystemMessage, - searchText: $searchText - ) - .id("system_message") - - if !chatViewModel.sortedMessages.isEmpty { - ForEach(chatViewModel.sortedMessages, id: \.objectID) { messageEntity in - let storedDuration = messageEntity.reasoningDuration > 0 ? messageEntity.reasoningDuration : nil - let bubbleContent = ChatBubbleContent( - message: messageEntity.body, - own: messageEntity.own, - waitingForResponse: messageEntity.waitingForResponse, - errorMessage: nil, - systemMessage: false, - isStreaming: isStreaming, - isLatestMessage: messageEntity.objectID == chatViewModel.sortedMessages.last?.objectID, - reasoningDuration: reasoningDurations[messageEntity.objectID] ?? storedDuration, - isActiveReasoning: messageEntity.objectID == activeReasoningMessageID - ) - ChatBubbleView(content: bubbleContent, message: messageEntity, searchText: $searchText, currentSearchOccurrence: chatViewModel.currentSearchOccurrence) - .id(messageEntity.objectID) - } - } - - if shouldShowWaitingBubble { - let bubbleContent = ChatBubbleContent( - message: "", - own: false, - waitingForResponse: true, - errorMessage: nil, - systemMessage: false, - isStreaming: isStreaming, - isLatestMessage: false, - reasoningDuration: nil, - isActiveReasoning: false - ) - - ChatBubbleView(content: bubbleContent, searchText: $searchText) - .id(-1) - } - else if let error = currentError { - let bubbleContent = ChatBubbleContent( - message: "", - own: false, - waitingForResponse: false, - errorMessage: error, - systemMessage: false, - isStreaming: isStreaming, - isLatestMessage: true, - reasoningDuration: nil, - isActiveReasoning: false - ) - - ChatBubbleView(content: bubbleContent, searchText: $searchText) - .id(-2) - } - } - .padding(24) - .onAppear { - pendingCodeBlocks = chatViewModel.sortedMessages.reduce(0) { count, message in - count + (message.body.components(separatedBy: "```").count - 1) / 2 - } - isInitialLoad = true - - if pendingCodeBlocks == 0 { - codeBlocksRendered = true - isInitialLoad = false - } - } - .onSwipe { event in - switch event.direction { - case .up: - userIsScrolling = true - case .none, .down, .left, .right: - break - } - } - .onChange(of: chat.lastMessage?.body) { _ in - if isStreaming && !userIsScrolling { - scrollDebounceWorkItem?.cancel() - - let workItem = DispatchWorkItem { - if let lastMessage = chatViewModel.sortedMessages.last { - withAnimation(.easeOut(duration: 1)) { - scrollView.scrollTo(lastMessage.objectID, anchor: .bottom) - } - } - } + VStack(spacing: 0) { + messagesList + .padding(24) - scrollDebounceWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) - } + // Invisible sentinel: the true bottom of the scrollable content. + // It sits below the list padding, so scrolling to it lands exactly + // at the end — a sentinel inside the padded stack stops 24 pt short. + Color.clear + .frame(height: 1) + .id(Self.bottomAnchorID) } - .onChange(of: chatViewModel.sortedMessages.count) { _ in + .onChange(of: chatViewModel.sortedMessages.count) { _, _ in + // A message was just sent (or an error arrived): bring the + // conversation tail into view even if the user had scrolled up. if shouldShowWaitingBubble || currentError != nil { withAnimation { - scrollView.scrollTo(-1) - } - } - } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("NonStreamingMessageCompleted"))) { notification in - if let notificationChat = notification.object as? ChatEntity, notificationChat == chat { - DispatchQueue.main.async { - if !isStreaming && !userIsScrolling { - let sortedMessages = chatViewModel.sortedMessages - if let lastMessage = sortedMessages.last { - withAnimation(.easeOut(duration: 0.5)) { - scrollView.scrollTo(lastMessage.objectID, anchor: .bottom) - } - } - } + scrollView.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("CodeBlockRendered"))) { _ in - if pendingCodeBlocks > 0 { - pendingCodeBlocks -= 1 - if pendingCodeBlocks == 0 { - codeBlocksRendered = true - if isInitialLoad { - isInitialLoad = false - if let lastMessage = chatViewModel.sortedMessages.last { - DispatchQueue.main.async { - scrollView.scrollTo(lastMessage.objectID, anchor: .bottom) - } - } - } - } - } - } - .onChange(of: chatViewModel.currentSearchOccurrence) { newOccurrence in + .onChange(of: chatViewModel.currentSearchOccurrence) { _, newOccurrence in if let occurrence = newOccurrence { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // Generate element ID for the occurrence @@ -181,7 +68,20 @@ struct ChatMessagesView: View { } .id("chatContainer") } + // Open at the bottom of the conversation… .defaultScrollAnchor(.bottom) + // …and keep the bottom pinned while content grows (streaming tokens, + // async code highlighting, image loading). The pin is released as soon + // as the user scrolls away and re-engaged when they scroll back down; + // the scroll view adjusts the offset within the same layout pass, so + // there is no post-hoc "jump to bottom" flicker. + .defaultScrollAnchor(userIsScrolling ? nil : .bottom, for: .sizeChanges) + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y + geometry.containerSize.height + >= geometry.contentSize.height + geometry.contentInsets.bottom - Self.bottomTolerance + } action: { _, isAtBottom in + userIsScrolling = !isAtBottom + } .padding(.bottom, 6) .overlay(alignment: .bottom) { LinearGradient( @@ -200,4 +100,69 @@ struct ChatMessagesView: View { .allowsHitTesting(false) } } + + private var messagesList: some View { + VStack { + SystemMessageBubbleView( + message: chat.systemMessage, + color: chat.persona?.color, + newMessage: $newMessage, + editSystemMessage: $editSystemMessage, + searchText: $searchText + ) + .id("system_message") + + if !chatViewModel.sortedMessages.isEmpty { + ForEach(chatViewModel.sortedMessages, id: \.objectID) { messageEntity in + let storedDuration = messageEntity.reasoningDuration > 0 ? messageEntity.reasoningDuration : nil + let bubbleContent = ChatBubbleContent( + message: messageEntity.body, + own: messageEntity.own, + waitingForResponse: messageEntity.waitingForResponse, + errorMessage: nil, + systemMessage: false, + isStreaming: isStreaming, + isLatestMessage: messageEntity.objectID == chatViewModel.sortedMessages.last?.objectID, + reasoningDuration: reasoningDurations[messageEntity.objectID] ?? storedDuration, + isActiveReasoning: messageEntity.objectID == activeReasoningMessageID + ) + ChatBubbleView(content: bubbleContent, message: messageEntity, searchText: $searchText, currentSearchOccurrence: chatViewModel.currentSearchOccurrence) + .id(messageEntity.objectID) + } + } + + if shouldShowWaitingBubble { + let bubbleContent = ChatBubbleContent( + message: "", + own: false, + waitingForResponse: true, + errorMessage: nil, + systemMessage: false, + isStreaming: isStreaming, + isLatestMessage: false, + reasoningDuration: nil, + isActiveReasoning: false + ) + + ChatBubbleView(content: bubbleContent, searchText: $searchText) + .id(-1) + } + else if let error = currentError { + let bubbleContent = ChatBubbleContent( + message: "", + own: false, + waitingForResponse: false, + errorMessage: error, + systemMessage: false, + isStreaming: isStreaming, + isLatestMessage: true, + reasoningDuration: nil, + isActiveReasoning: false + ) + + ChatBubbleView(content: bubbleContent, searchText: $searchText) + .id(-2) + } + } + } } diff --git a/macai/UI/Chat/ChatSettingsView.swift b/macai/UI/Chat/ChatSettingsView.swift new file mode 100644 index 0000000..55b75a7 --- /dev/null +++ b/macai/UI/Chat/ChatSettingsView.swift @@ -0,0 +1,432 @@ +// +// ChatSettingsView.swift +// macai +// +// Created by Codex on 16.01.2026. +// + +import AppKit +import CoreData +import SwiftUI + +/// Per-chat settings panel. Every control writes to the chat only: values the +/// chat has not overridden are inherited live from its assistant. Assistants +/// themselves are edited in Settings → AI Assistants. +struct ChatSettingsView: View { + @Environment(\.managedObjectContext) private var viewContext + @ObservedObject var chat: ChatEntity + let onServiceChange: (APIServiceEntity) -> Void + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \APIServiceEntity.addedDate, ascending: false)], + animation: .default + ) + private var apiServices: FetchedResults + + @State private var selectedServiceID: NSManagedObjectID? + @State private var selectedModel = "" + @State private var isCustomModel = false + @State private var customModel = "" + @State private var fetchedModels: [String] = [] + @State private var isLoadingModels = false + @State private var pendingSaveWorkItem: DispatchWorkItem? + + private var availableModels: [String] { + if fetchedModels.isEmpty == false { + return fetchedModels + } + guard let type = chat.effectiveApiService?.type else { return [] } + return AppConstants.defaultApiConfigurations[type]?.models ?? [] + } + + private var serviceSupportsImageGeneration: Bool { + chat.effectiveApiService?.imageGenerationSupported ?? false + } + + var body: some View { + content + } + + @ViewBuilder + private var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + headerSection + assistantSection + Divider() + apiServiceSection + Divider() + modelSection + Divider() + temperatureSection + Divider() + contextSizeSection + Divider() + toolsSection + resetSection + } + .padding(16) + // Fully flexible frame so the controls' natural minimums don't + // dictate the panel width (the parent owns sizing). + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + .onAppear { + syncServiceSelection() + refreshModels() + } + .onDisappear { + flushPendingSave() + } + .onChange(of: chat.persona?.objectID) { _, _ in + flushPendingSave() + syncServiceSelection() + refreshModels() + } + .onChange(of: chat.effectiveApiService?.objectID) { _, _ in + syncServiceSelection() + refreshModels() + } + } + + private var headerSection: some View { + HStack { + Text("Chat Settings") + .font(.headline) + Spacer() + } + } + + private var assistantSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text(chat.persona?.name ?? "No Assistant") + .font(.title3) + Text("Settings below apply to this chat only. Assistant defaults are edited in Settings → AI Assistants.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var apiServiceSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("API Service") + .font(.subheadline) + .foregroundStyle(.secondary) + + Picker("API Service", selection: $selectedServiceID) { + Text("None").tag(nil as NSManagedObjectID?) + ForEach(apiServices) { service in + Text(service.name ?? "Untitled") + .tag(service.objectID as NSManagedObjectID?) + } + } + .labelsHidden() + .onChange(of: selectedServiceID) { _, newValue in + guard let newID = newValue else { return } + guard let newService = apiServices.first(where: { $0.objectID == newID }) else { return } + if newService.objectID != chat.effectiveApiService?.objectID { + onServiceChange(newService) + } + } + } + } + + @ViewBuilder + private var modelSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text("Model") + .font(.subheadline) + .foregroundStyle(.secondary) + // Kept in the hierarchy permanently so the row height is stable; + // toggling opacity avoids layout jumps below. + ProgressView() + .controlSize(.small) + .opacity(isLoadingModels ? 1 : 0) + .accessibilityLabel("Loading models") + .accessibilityHidden(!isLoadingModels) + } + + if availableModels.isEmpty { + TextField("Enter model name", text: customModelBinding) + .textFieldStyle(RoundedBorderTextFieldStyle()) + } else { + Picker("Model", selection: $selectedModel) { + ForEach(availableModels.sorted(), id: \.self) { modelName in + Text(modelName).tag(modelName) + } + Divider() + Text("Custom Model…").tag("custom") + } + .labelsHidden() + .onChange(of: selectedModel) { _, newValue in + if newValue == "custom" { + isCustomModel = true + if customModel.isEmpty { + customModel = chat.effectiveModel + } + } else { + isCustomModel = false + customModel = newValue + updateModel(newValue) + } + } + + if isCustomModel { + TextField("Enter custom model name", text: $customModel) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onChange(of: customModel) { _, newValue in + updateModel(newValue) + } + } + } + } + } + + private var temperatureSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Temperature") + .font(.subheadline) + .foregroundStyle(.secondary) + + Slider( + value: temperatureBinding, + in: 0...1, + step: 0.1 + ) + HStack { + Text("0.0") + Spacer() + Text("\(String(format: "%.1f", chat.effectiveTemperature)) · \(chat.effectiveTemperature.temperatureDescriptor)") + Spacer() + Text("1.0") + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var contextSizeSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Context size") + .font(.subheadline) + .foregroundStyle(.secondary) + + Slider( + value: contextSizeBinding, + in: 5...100, + step: 5 + ) + + Text("\(chat.effectiveContextSize) messages") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var toolsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Tools") + .font(.subheadline) + .foregroundStyle(.secondary) + + Toggle("Image generation", isOn: imageGenerationBinding) + .disabled(!serviceSupportsImageGeneration) + .help(serviceSupportsImageGeneration ? "" : "This API service does not support image generation.") + + capabilityRow("Image uploads", isAvailable: chat.allowsImageUploads) + capabilityRow("PDF uploads", isAvailable: chat.allowsPdfUploads) + + Text("Uploads depend on the API service and can be changed in Settings → API Services.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var resetSection: some View { + if chat.persona != nil { + VStack(alignment: .leading, spacing: 8) { + Divider() + Button("Reset to Assistant Defaults") { + resetToAssistantDefaults() + } + } + } + } + + private func capabilityRow(_ title: String, isAvailable: Bool) -> some View { + HStack(spacing: 6) { + Image(systemName: isAvailable ? "checkmark.circle.fill" : "minus.circle") + .foregroundStyle(isAvailable ? Color.green : Color.secondary) + Text(title) + .foregroundStyle(isAvailable ? Color.primary : Color.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(title): \(isAvailable ? "available" : "not available with this API service")") + } + + // MARK: - Bindings (all write to the chat, never to the assistant) + + private var temperatureBinding: Binding { + Binding( + get: { Double(chat.effectiveTemperature) }, + set: { newValue in + chat.temperature = newValue + chat.temperatureOverridden = true + scheduleDebouncedSave() + } + ) + } + + private var contextSizeBinding: Binding { + Binding( + get: { Double(chat.effectiveContextSize) }, + set: { newValue in + chat.contextSize = Int16(newValue) + scheduleDebouncedSave() + } + ) + } + + private var imageGenerationBinding: Binding { + Binding( + get: { chat.imageGenerationEnabled }, + set: { newValue in + chat.imageGenerationMode = newValue ? 1 : 2 + viewContext.saveWithRetry(attempts: 1) + } + ) + } + + private var customModelBinding: Binding { + Binding( + get: { + customModel.isEmpty ? chat.effectiveModel : customModel + }, + set: { newValue in + customModel = newValue + updateModel(newValue) + } + ) + } + + // MARK: - Actions + + private func updateModel(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false, chat.gptModel != trimmed else { return } + chat.gptModel = trimmed + chat.modelOverridden = true + scheduleDebouncedSave() + } + + private func resetToAssistantDefaults() { + flushPendingSave() + chat.resetToAssistantDefaults() + chat.objectWillChange.send() + viewContext.saveWithRetry(attempts: 1) + syncServiceSelection() + refreshModels() + } + + /// Coalesces rapid-fire edits (slider drags, typing) into one Core Data + /// save — every save also triggers a CloudKit sync export. + private func scheduleDebouncedSave() { + pendingSaveWorkItem?.cancel() + let workItem = DispatchWorkItem { + viewContext.saveWithRetry(attempts: 1) + } + pendingSaveWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) + } + + private func flushPendingSave() { + pendingSaveWorkItem?.cancel() + pendingSaveWorkItem = nil + if viewContext.hasChanges { + viewContext.saveWithRetry(attempts: 1) + } + } + + // MARK: - Selection sync + + private func syncServiceSelection() { + let currentID = chat.effectiveApiService?.objectID + if let currentID, + apiServices.contains(where: { $0.objectID == currentID }) { + selectedServiceID = currentID + } else { + selectedServiceID = nil + } + } + + private func refreshModels() { + guard let service = chat.effectiveApiService else { + fetchedModels = [] + isLoadingModels = false + syncModelSelection() + return + } + + // Show the cached list right away so the selected model is visible + // immediately; a background refresh updates it afterwards. + if let serviceID = service.id, let cached = ModelsCache.shared.models(for: serviceID) { + fetchedModels = cached + } else { + fetchedModels = [] + } + syncModelSelection() + + guard let config = AppConstants.defaultApiConfigurations[service.type ?? ""], + config.modelsFetching else { + isLoadingModels = false + return + } + + let requestedServiceID = service.objectID + isLoadingModels = true + Task { + let models = await APIServiceManager.fetchRemoteModels( + context: viewContext, + serviceID: requestedServiceID + ) + await MainActor.run { + // Ignore responses that arrive after the user switched services or chats. + guard chat.effectiveApiService?.objectID == requestedServiceID else { return } + isLoadingModels = false + if let models, !models.isEmpty, models != fetchedModels { + fetchedModels = models + syncModelSelection() + } + } + } + } + + private func syncModelSelection() { + let currentModel = chat.effectiveModel + + if availableModels.isEmpty { + selectedModel = currentModel + isCustomModel = true + customModel = currentModel + return + } + + if availableModels.contains(currentModel) { + selectedModel = currentModel + isCustomModel = false + customModel = currentModel + } else { + selectedModel = "custom" + isCustomModel = true + customModel = currentModel + } + } +} + +#Preview { + let context = PersistenceController.preview.container.viewContext + let chat = ChatEntity(context: context) + return ChatSettingsView(chat: chat, onServiceChange: { _ in }) +} diff --git a/macai/UI/Chat/ChatView.swift b/macai/UI/Chat/ChatView.swift index feedda0..8ddeaab 100644 --- a/macai/UI/Chat/ChatView.swift +++ b/macai/UI/Chat/ChatView.swift @@ -14,6 +14,7 @@ struct ChatView: View { let viewContext: NSManagedObjectContext @State var chat: ChatEntity @Binding var searchText: String + @Binding var isSearchPresented: Bool @AppStorage("lastOpenedChatId") var lastOpenedChatId = "" // UI State @@ -41,10 +42,16 @@ struct ChatView: View { private let reasoningTimer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect() // MARK: - Initialization - init(viewContext: NSManagedObjectContext, chat: ChatEntity, searchText: Binding) { + init( + viewContext: NSManagedObjectContext, + chat: ChatEntity, + searchText: Binding, + isSearchPresented: Binding + ) { self.viewContext = viewContext self._chat = State(initialValue: chat) self._searchText = searchText + self._isSearchPresented = isSearchPresented // Initialize view models let viewModel = ChatViewModel(chat: chat, viewContext: viewContext) @@ -69,15 +76,15 @@ struct ChatView: View { } private var pdfUploadsAllowed: Bool { - chat.apiService?.pdfUploadsAllowed ?? false + chat.allowsPdfUploads } private var imageUploadsAllowed: Bool { - chat.apiService?.imageUploadsAllowed ?? false + chat.allowsImageUploads } private var imageGenerationSupported: Bool { - chat.apiService?.imageGenerationSupported ?? false + chat.supportsImageGeneration } private var isInferenceInProgress: Bool { @@ -104,12 +111,10 @@ struct ChatView: View { .navigationTitle(chat.name != "" ? chat.name : chat.persona?.name ?? "macai LLM chat") .onAppear(perform: { self.lastOpenedChatId = chat.id.uuidString - print("lastOpenedChatId: \(lastOpenedChatId)") - Self._printChanges() - DispatchQueue.main.asyncAfter(deadline: .now()) { - let startTime = CFAbsoluteTimeGetCurrent() - _ = self.body - renderTime = CFAbsoluteTimeGetCurrent() - startTime + // Re-run search when switching to this chat with a query already entered: + // onChange(of: searchText) won't fire because the text didn't change. + if !searchText.isEmpty { + chatViewModel.updateSearchOccurrences(searchText: searchText) } }) .onDisappear { @@ -165,19 +170,19 @@ struct ChatView: View { SearchNavigationView(chatViewModel: chatViewModel) } } - .onChange(of: searchText) { newSearchText in + .onChange(of: searchText) { _, newSearchText in chatViewModel.updateSearchOccurrences(searchText: newSearchText) } - .onChange(of: draftSignature) { _ in + .onChange(of: draftSignature) { _, _ in guard !editSystemMessage else { return } scheduleDraftSave() } - .onChange(of: editSystemMessage) { isEditing in + .onChange(of: editSystemMessage) { _, isEditing in if !isEditing, newMessage.isEmpty { newMessage = draftManager.draftMessage(for: chat) } } - .onChange(of: chat.lastMessage?.body) { _ in + .onChange(of: chat.lastMessage?.body) { _, _ in guard let lastMessage = chat.lastMessage, !lastMessage.own else { return } updateReasoningTiming(for: lastMessage, isStreamingActive: logicHandler.isStreaming) } @@ -226,19 +231,21 @@ struct ChatView: View { private func handleSendMessage() { lastRequestStartTime = Date() - logicHandler.sendMessage( + let didSend = logicHandler.sendMessage( messageText: newMessage, attachedImages: attachedImages, attachedFiles: attachedFiles ) - newMessage = "" - attachedImages = [] - attachedFiles = [] - draftManager.clearDraft(chat: chat) - - if chatViewModel.sortedMessages.count == 1 { // First message just sent - withAnimation { - isBottomContainerExpanded = false + if didSend { + newMessage = "" + attachedImages = [] + attachedFiles = [] + draftManager.clearDraft(chat: chat) + + if chatViewModel.sortedMessages.count == 1 { // First message just sent + withAnimation { + isBottomContainerExpanded = false + } } } } diff --git a/macai/UI/Chat/ChatViewModel.swift b/macai/UI/Chat/ChatViewModel.swift index 56814f9..413359d 100644 --- a/macai/UI/Chat/ChatViewModel.swift +++ b/macai/UI/Chat/ChatViewModel.swift @@ -49,26 +49,37 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg private let fetchedResultsController: NSFetchedResultsController private var _messageManager: MessageManager? - private var messageManager: MessageManager { - get { - if _messageManager == nil { - _messageManager = createMessageManager() - } - return _messageManager! + private var needsMessageManagerRecreate = false + private var messageManager: MessageManager? { + if needsMessageManagerRecreate, !chat.waitingForResponse { + needsMessageManagerRecreate = false + _messageManager = nil } - set { - _messageManager = newValue + if _messageManager == nil { + _messageManager = createMessageManager() } + return _messageManager } private var cancellables = Set() private var searchDebounceTimer: Timer? private var isApplyingServiceChanges = false - - init(chat: ChatEntity, viewContext: NSManagedObjectContext) { + private var lastObservedPersonaID: NSManagedObjectID? + private var lastObservedServiceID: NSManagedObjectID? + private var lastObservedModel: String? + private var lastObservedImageGenerationMode: Int16 = 0 + + /// - Parameter loadsMessages: Pass `false` for lightweight use cases (e.g. chat list rows) + /// that only need actions like name regeneration. Skips fetching all chat messages and + /// observing Core Data changes, which would otherwise run for every visible row. + init(chat: ChatEntity, viewContext: NSManagedObjectContext, loadsMessages: Bool = true) { self.chat = chat self.messages = chat.messages self.viewContext = viewContext + self.lastObservedPersonaID = chat.persona?.objectID + self.lastObservedServiceID = chat.effectiveApiService?.objectID + self.lastObservedModel = chat.gptModel + self.lastObservedImageGenerationMode = chat.imageGenerationMode let fetchRequest: NSFetchRequest = MessageEntity.fetchRequest() as! NSFetchRequest fetchRequest.predicate = NSPredicate(format: "chat == %@", chat) @@ -83,6 +94,8 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg super.init() + guard loadsMessages else { return } + self.fetchedResultsController.delegate = self do { @@ -108,11 +121,15 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg contextSize: Int, completion: @escaping (Result) -> Void ) { - self.messageManager.sendMessage( + guard let messageManager else { + completion(.failure(APIError.noApiService("No valid API configuration found"))) + return + } + messageManager.sendMessage( message, in: chat, contextSize: contextSize - ) { [weak self] result in + ) { result in switch result { case .success: completion(.success(())) @@ -124,7 +141,11 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg @MainActor func sendMessageStream(_ message: String, contextSize: Int, completion: @escaping (Result) -> Void) { - self.messageManager.sendMessageStream(message, in: chat, contextSize: contextSize) { [weak self] result in + guard let messageManager else { + completion(.failure(APIError.noApiService("No valid API configuration found"))) + return + } + messageManager.sendMessageStream(message, in: chat, contextSize: contextSize) { [weak self] result in switch result { case .success: self?.chat.objectWillChange.send() @@ -136,11 +157,11 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg } func stopInference() { - messageManager.cancelCurrentRequest() + _messageManager?.cancelCurrentRequest() } func generateChatNameIfNeeded() { - messageManager.generateChatNameIfNeeded(chat: chat) + messageManager?.generateChatNameIfNeeded(chat: chat) } func reloadMessages() { @@ -150,12 +171,12 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg // sortedMessages is now a @Published property initialized in init() and updated in reloadMessages() - private func createMessageManager() -> MessageManager { + private func createMessageManager() -> MessageManager? { guard let config = self.loadCurrentAPIConfig() else { - fatalError("No valid API configuration found") + print("Cannot create MessageManager: no valid API configuration found") + return nil } - print(">> Creating new MessageManager with URL: \(config.apiUrl) and model: \(config.model)") - let supportsImageGeneration = chat.apiService?.imageGenerationSupported ?? false + let supportsImageGeneration = chat.supportsImageGeneration return MessageManager( apiService: APIServiceFactory.createAPIService( config: config, @@ -166,7 +187,14 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg } func recreateMessageManager() { - _messageManager = createMessageManager() + // While a response is in flight, keep the current manager alive so + // Stop still targets the instance that owns the request; the lazy + // getter rebuilds with fresh config on next use. + guard !chat.waitingForResponse else { + needsMessageManagerRecreate = true + return + } + _messageManager = nil } // MARK: - Service change observation @@ -178,29 +206,49 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg .receive(on: RunLoop.main) .sink { [weak self] notification in guard let self = self, - !self.isApplyingServiceChanges, - let serviceObjectID = self.chat.apiService?.objectID else { return } + !self.isApplyingServiceChanges else { return } let updated = notification.userInfo?[NSUpdatedObjectsKey] as? Set ?? [] let refreshed = notification.userInfo?[NSRefreshedObjectsKey] as? Set ?? [] - let changedServices = updated.union(refreshed) - - guard changedServices.contains(where: { $0.objectID == serviceObjectID }) else { return } + let changedObjects = updated.union(refreshed) + + let chatID = self.chat.objectID + let personaID = self.chat.persona?.objectID + let serviceID = self.chat.effectiveApiService?.objectID + + let personaObjectChanged = personaID != nil + && changedObjects.contains(where: { $0.objectID == personaID }) + let serviceObjectChanged = serviceID != nil + && changedObjects.contains(where: { $0.objectID == serviceID }) + let chatObjectChanged = changedObjects.contains(where: { $0.objectID == chatID }) + + let relationshipChanged = personaID != self.lastObservedPersonaID + || serviceID != self.lastObservedServiceID + // Per-chat settings baked into the MessageManager config: a change + // requires a rebuild even though no relationship moved. + let modelChanged = self.chat.gptModel != self.lastObservedModel + let imageModeChanged = self.chat.imageGenerationMode != self.lastObservedImageGenerationMode + let chatConfigChanged = relationshipChanged || modelChanged || imageModeChanged + + guard personaObjectChanged || serviceObjectChanged || (chatObjectChanged && chatConfigChanged) else { + return + } - self.applyAPIServiceChanges() + self.lastObservedPersonaID = personaID + self.lastObservedServiceID = serviceID + self.lastObservedModel = self.chat.gptModel + self.lastObservedImageGenerationMode = self.chat.imageGenerationMode + self.applyAPIServiceChanges(forceRecreate: personaObjectChanged || serviceObjectChanged || chatConfigChanged) } } - private func applyAPIServiceChanges() { + private func applyAPIServiceChanges(forceRecreate: Bool) { let chatID = chat.objectID - let apiServiceID = chat.apiService?.objectID viewContext.perform { [weak self] in guard let self else { return } guard !self.isApplyingServiceChanges else { return } - guard let apiServiceID, - let chat = try? self.viewContext.existingObject(with: chatID) as? ChatEntity, - let apiService = try? self.viewContext.existingObject(with: apiServiceID) as? APIServiceEntity, + guard let chat = try? self.viewContext.existingObject(with: chatID) as? ChatEntity, !chat.isDeleted else { return } @@ -208,24 +256,28 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg self.isApplyingServiceChanges = true defer { self.isApplyingServiceChanges = false } + // The chat's own service/model are authoritative (per-chat settings); + // only backfill from the assistant when the chat has no value yet. var didChange = false - - let newModel = apiService.model ?? AppConstants.defaultModel(for: apiService.type) - if chat.gptModel != newModel { - chat.gptModel = newModel + if chat.apiService == nil, let personaService = chat.persona?.apiService { + chat.apiService = personaService didChange = true } - // Ensure we keep the latest managed object instance (it may have been refreshed) - if chat.apiService != apiService { - chat.apiService = apiService + if chat.gptModel.isEmpty { + let personaModel = chat.persona?.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + chat.gptModel = personaModel.isEmpty + ? AppConstants.defaultModel(for: chat.effectiveApiService?.type) + : personaModel didChange = true } - if didChange { + if didChange || forceRecreate { chat.objectWillChange.send() self.recreateMessageManager() - try? self.viewContext.save() + if didChange { + try? self.viewContext.save() + } } } } @@ -235,11 +287,11 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg } var canSendMessage: Bool { - return chat.apiService != nil + return chat.effectiveApiService != nil } private func loadCurrentAPIConfig() -> APIServiceConfiguration? { - guard let apiService = chat.apiService, let apiServiceUrl = apiService.url else { + guard let apiService = chat.effectiveApiService, let apiServiceUrl = apiService.url else { return nil } @@ -260,16 +312,21 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg name: getApiServiceName(), apiUrl: apiServiceUrl, apiKey: apiKey, - model: chat.gptModel + model: chat.effectiveModel, + customHeaders: APIServiceHeaderStore.load(for: serviceID) ) } private func getApiServiceName() -> String { - return chat.apiService?.type ?? AppConstants.defaultApiType + return chat.effectiveApiService?.type ?? AppConstants.defaultApiType } - func regenerateChatName() { - messageManager.generateChatNameIfNeeded(chat: chat, force: true) + func regenerateChatName(completion: ((Result) -> Void)? = nil) { + guard let messageManager else { + completion?(.failure(APIError.noApiService("No valid API configuration found"))) + return + } + messageManager.generateChatNameIfNeeded(chat: chat, force: true, completion: completion) } // MARK: - Search Logic diff --git a/macai/UI/Chat/CodeView/CodeView.swift b/macai/UI/Chat/CodeView/CodeView.swift index f29a7f7..3b3350b 100644 --- a/macai/UI/Chat/CodeView/CodeView.swift +++ b/macai/UI/Chat/CodeView/CodeView.swift @@ -5,7 +5,6 @@ // Created by Renat on 07.06.2024. // -import AttributedText import Foundation import Highlightr import SwiftUI @@ -64,16 +63,16 @@ struct CodeView: View { } .background(codeBackground) .clipShape(RoundedRectangle(cornerRadius: 8.0)) - .onChange(of: colorScheme) { newScheme in + .onChange(of: colorScheme) { _, newScheme in updateHighlightedCode(colorScheme: newScheme) } - .onChange(of: code) { code in + .onChange(of: code) { _, code in if isStreaming { print("Code changed: \(code)") updateHighlightedCode(colorScheme: colorScheme, code: code) } } - .onChange(of: highlightedCode) { _ in + .onChange(of: highlightedCode) { _, _ in if !isRendered { isRendered = true NotificationCenter.default.post( @@ -85,11 +84,11 @@ struct CodeView: View { .onAppear { updateHighlightedCode(colorScheme: colorScheme) } - .onChange(of: chatFontSize) { _ in + .onChange(of: chatFontSize) { _, _ in HighlighterManager.shared.invalidateCache() updateHighlightedCode(colorScheme: colorScheme) } - .onChange(of: codeFont) { _ in + .onChange(of: codeFont) { _, _ in HighlighterManager.shared.invalidateCache() updateHighlightedCode(colorScheme: colorScheme) } diff --git a/macai/UI/ChatList/ChatListRow.swift b/macai/UI/ChatList/ChatListRow.swift index bc26364..6863771 100644 --- a/macai/UI/ChatList/ChatListRow.swift +++ b/macai/UI/ChatList/ChatListRow.swift @@ -6,9 +6,18 @@ // import CoreData -import Foundation import SwiftUI +enum ChatListRowAction { + case beginSelection + case togglePin + case rename + case regenerateName + case clear + case export(ChatExportFormat) + case delete +} + struct ChatListRow: View, Equatable { static func == (lhs: ChatListRow, rhs: ChatListRow) -> Bool { lhs.chatObjectID == rhs.chatObjectID && @@ -17,7 +26,9 @@ struct ChatListRow: View, Equatable { lhs.lastMessageBody == rhs.lastMessageBody && lhs.isPinned == rhs.isPinned && lhs.showsAttentionIndicator == rhs.showsAttentionIndicator && - (lhs.selectedChat?.objectID == rhs.selectedChat?.objectID) && + lhs.isActive == rhs.isActive && + lhs.isSelectionMode == rhs.isSelectionMode && + lhs.isBulkSelected == rhs.isBulkSelected && lhs.searchText == rhs.searchText } let chat: ChatEntity @@ -29,17 +40,22 @@ struct ChatListRow: View, Equatable { let lastMessageTimestamp: Date let updatedDate: Date? let showsAttentionIndicator: Bool - @Binding var selectedChat: ChatEntity? - let viewContext: NSManagedObjectContext + let isActive: Bool + let isSelectionMode: Bool + let isBulkSelected: Bool let searchText: String - @StateObject private var chatViewModel: ChatViewModel + let onActivate: () -> Void + let onAction: (ChatListRowAction) -> Void init( chat: ChatEntity, showsAttentionIndicator: Bool, - selectedChat: Binding, - viewContext: NSManagedObjectContext, - searchText: String + isActive: Bool, + isSelectionMode: Bool, + isBulkSelected: Bool, + searchText: String, + onActivate: @escaping () -> Void, + onAction: @escaping (ChatListRowAction) -> Void ) { self.chat = chat self.chatObjectID = chat.objectID @@ -50,26 +66,12 @@ struct ChatListRow: View, Equatable { self.lastMessageTimestamp = chat.lastMessage?.timestamp ?? .distantPast self.updatedDate = chat.updatedDate self.showsAttentionIndicator = showsAttentionIndicator - self._selectedChat = selectedChat - self.viewContext = viewContext + self.isActive = isActive + self.isSelectionMode = isSelectionMode + self.isBulkSelected = isBulkSelected self.searchText = searchText - self._chatViewModel = StateObject(wrappedValue: ChatViewModel(chat: chat, viewContext: viewContext)) - } - - var isActive: Binding { - Binding( - get: { - selectedChat == chat - }, - set: { newValue in - if newValue { - selectedChat = chat - } - else { - selectedChat = nil - } - } - ) + self.onActivate = onActivate + self.onAction = onAction } var body: some View { @@ -82,111 +84,47 @@ struct ChatListRow: View, Equatable { showsAttentionIndicator: showsAttentionIndicator, isPinned: isPinned, isActive: isActive, - searchText: searchText + isSelectionMode: isSelectionMode, + isBulkSelected: isBulkSelected, + searchText: searchText, + onActivate: onActivate ) .contextMenu { - Button(action: { - togglePinChat(chat) - }) { + Button(action: { onAction(.beginSelection) }) { + Label("Select", systemImage: "checkmark.circle") + } + + Divider() + + Button(action: { onAction(.togglePin) }) { Label(isPinned ? "Unpin" : "Pin", systemImage: isPinned ? "pin.slash" : "pin") } - Button(action: { renameChat(chat) }) { + Button(action: { onAction(.rename) }) { Label("Rename", systemImage: "pencil") } - if chat.apiService?.generateChatNames ?? false { - Button(action: { - chatViewModel.regenerateChatName() - }) { + if chat.shouldGenerateChatNames { + Button(action: { onAction(.regenerateName) }) { Label("Regenerate Name", systemImage: "arrow.clockwise") } } - Button(action: { clearChat(chat) }) { + Button(action: { onAction(.clear) }) { Label("Clear Chat", systemImage: "eraser") } - Divider() - Button(action: { deleteChat(chat) }) { - Label("Delete", systemImage: "trash") - } - } - } - - func deleteChat(_ chat: ChatEntity) { - guard !chat.isDeleted else { return } - let alert = NSAlert() - alert.messageText = "Delete chat \(chat.name)?" - alert.informativeText = "Are you sure you want to delete this chat?" - alert.addButton(withTitle: "Delete") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: NSApp.keyWindow!) { response in - if response == .alertFirstButtonReturn { - // Clear selectedChat to prevent accessing deleted item - if selectedChat?.objectID == chat.objectID { - selectedChat = nil - } - viewContext.delete(chat) - DispatchQueue.main.async { - do { - try viewContext.save() - } - catch { - print("Error deleting chat: \(error.localizedDescription)") - } - } - } - } - } - - func renameChat(_ chat: ChatEntity) { - let alert = NSAlert() - alert.messageText = "Rename chat" - alert.informativeText = "Enter new name for this chat" - alert.addButton(withTitle: "Rename") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .informational - let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - textField.stringValue = chat.name - alert.accessoryView = textField - alert.beginSheetModal(for: NSApp.keyWindow!) { response in - if response == .alertFirstButtonReturn { - chat.name = textField.stringValue - do { - try viewContext.save() + Menu { + Button("As Markdown…") { + onAction(.export(.markdown)) } - - catch { - print("Error renaming chat: \(error.localizedDescription)") + Button("As Plain Text…") { + onAction(.export(.plainText)) } + } label: { + Label("Export Chat", systemImage: "square.and.arrow.up") } - } - } - - func clearChat(_ chat: ChatEntity) { - let alert = NSAlert() - alert.messageText = "Clear chat \(chat.name)?" - alert.informativeText = "Are you sure you want to delete all messages from this chat? Chat parameters will not be deleted. This action cannot be undone." - alert.addButton(withTitle: "Clear") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: NSApp.keyWindow!) { response in - if response == .alertFirstButtonReturn { - chat.clearMessages() - do { - try viewContext.save() - } catch { - print("Error clearing chat: \(error.localizedDescription)") - } + Divider() + Button(action: { onAction(.delete) }) { + Label("Delete", systemImage: "trash") } } } - - func togglePinChat(_ chat: ChatEntity) { - chat.isPinned.toggle() - do { - try viewContext.save() - } catch { - print("Error toggling pin status: \(error.localizedDescription)") - } - } } diff --git a/macai/UI/ChatList/ChatListView.swift b/macai/UI/ChatList/ChatListView.swift index c14bfba..de72f59 100644 --- a/macai/UI/ChatList/ChatListView.swift +++ b/macai/UI/ChatList/ChatListView.swift @@ -5,16 +5,17 @@ // Created by Renat on 17.11.2024. // +import AppKit import CoreData import SwiftUI struct ChatListView: View { @Environment(\.managedObjectContext) private var viewContext @EnvironmentObject private var attentionStore: ChatAttentionStore - @State private var scrollOffset: CGFloat = 0 - @State private var previousOffset: CGFloat = 0 @State private var debouncedSearchText: String = "" @State private var debounceTimer: Timer? + @State private var selectedChatIDs = Set() + @StateObject private var nameGenerator = ChatNameGenerationCoordinator() @FetchRequest( entity: ChatEntity.entity(), @@ -28,6 +29,7 @@ struct ChatListView: View { @Binding var selectedChat: ChatEntity? @Binding var searchText: String + @Binding var isSelectionMode: Bool private var filteredChats: [ChatEntity] { if debouncedSearchText.isEmpty { @@ -70,24 +72,34 @@ struct ChatListView: View { } var body: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(filteredChats, id: \.objectID) { chat in - ChatListRow( - chat: chat, - showsAttentionIndicator: attentionStore.contains(chat.id), - selectedChat: $selectedChat, - viewContext: viewContext, - searchText: debouncedSearchText - ) - .id(chat.objectID) + VStack(spacing: 0) { + ScrollViewReader { _ in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(filteredChats, id: \.objectID) { chat in + ChatListRow( + chat: chat, + showsAttentionIndicator: attentionStore.contains(chat.id), + isActive: selectedChat?.objectID == chat.objectID, + isSelectionMode: isSelectionMode, + isBulkSelected: selectedChatIDs.contains(chat.objectID), + searchText: debouncedSearchText, + onActivate: { activate(chat) }, + onAction: { handle($0, for: chat) } + ) + .id(chat.objectID) + } } + .padding(12) } - .padding(12) } } - .onChange(of: searchText) { newValue in + .safeAreaInset(edge: .bottom, spacing: 0) { + if isSelectionMode { + bulkActionBar + } + } + .onChange(of: searchText) { _, newValue in debounceTimer?.invalidate() debounceTimer = Timer.scheduledTimer(withTimeInterval: AppConstants.searchDebounceTime, repeats: false) { _ in @@ -100,5 +112,303 @@ struct ChatListView: View { .onDisappear { debounceTimer?.invalidate() } + .onChange(of: isSelectionMode) { _, isSelecting in + if !isSelecting { + selectedChatIDs.removeAll() + } + } + .onChange(of: chats.map(\.objectID)) { _, availableIDs in + selectedChatIDs.formIntersection(Set(availableIDs)) + if chats.isEmpty { + isSelectionMode = false + } + } + .onChange(of: nameGenerator.failureMessage) { _, message in + guard let message else { return } + presentError(message) + nameGenerator.failureMessage = nil + } + } + + private var selectedChats: [ChatEntity] { + chats.filter { selectedChatIDs.contains($0.objectID) } + } + + private var allVisibleChatsSelected: Bool { + !filteredChats.isEmpty && filteredChats.allSatisfy { selectedChatIDs.contains($0.objectID) } + } + + private var bulkActionBar: some View { + HStack(spacing: 8) { + Button("Done") { + isSelectionMode = false + } + .buttonStyle(.borderless) + .keyboardShortcut(.cancelAction) + .help("Finish Selecting") + + Spacer(minLength: 4) + + Button(action: togglePinForSelectedChats) { + Label( + selectedChats.allSatisfy(\.isPinned) ? "Unpin Chats" : "Pin Chats", + systemImage: selectedChats.allSatisfy(\.isPinned) ? "pin.slash" : "pin" + ) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .disabled(selectedChatIDs.isEmpty) + .help(selectedChats.allSatisfy(\.isPinned) ? "Unpin Chats" : "Pin Chats") + + Menu { + Button(action: toggleAllVisibleChats) { + Label( + allVisibleChatsSelected ? "Deselect All" : "Select All", + systemImage: allVisibleChatsSelected ? "checkmark.circle.fill" : "checkmark.circle" + ) + } + .disabled(filteredChats.isEmpty) + + Divider() + + Button(action: generateNamesForSelectedChats) { + Label( + nameGenerator.isRunning ? "Generating Names…" : "Generate Names", + systemImage: nameGenerator.isRunning ? "hourglass" : "wand.and.stars" + ) + } + .disabled(selectedChatIDs.isEmpty || nameGenerator.isRunning) + + Menu("Export", systemImage: "square.and.arrow.up") { + Button("As Markdown…") { exportSelected(as: .markdown) } + Button("As Plain Text…") { exportSelected(as: .plainText) } + } + .disabled(selectedChatIDs.isEmpty) + + Button(role: .destructive, action: clearSelectedChats) { + Label("Clear Chats…", systemImage: "eraser") + } + .disabled(selectedChatIDs.isEmpty) + } label: { + Label("More Actions", systemImage: "ellipsis.circle") + } + .labelStyle(.iconOnly) + .menuStyle(.borderlessButton) + .fixedSize() + .help("More Actions") + + Button(role: .destructive, action: deleteSelectedChats) { + Label("Delete Chats", systemImage: "trash") + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .disabled(selectedChatIDs.isEmpty) + .help("Delete Chats") + } + .controlSize(.regular) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .background(.bar) + .overlay(alignment: .top) { Divider() } + .accessibilityElement(children: .contain) + .accessibilityLabel("Actions for \(selectedChatIDs.count) selected chats") + } + + private func toggleAllVisibleChats() { + if allVisibleChatsSelected { + selectedChatIDs.subtract(filteredChats.map(\.objectID)) + } + else { + selectedChatIDs.formUnion(filteredChats.map(\.objectID)) + } + } + + private func togglePinForSelectedChats() { + setPinned(selectedChats, to: !selectedChats.allSatisfy(\.isPinned)) + } + + private func generateNamesForSelectedChats() { + nameGenerator.generateNames(for: selectedChats, in: viewContext) + } + + private func clearSelectedChats() { + confirmClear(selectedChats) + } + + private func deleteSelectedChats() { + confirmDelete(selectedChats) + } + + private func activate(_ chat: ChatEntity) { + if isSelectionMode { + toggleSelection(of: chat) + } + else { + selectedChat = chat + } + } + + private func toggleSelection(of chat: ChatEntity) { + if !selectedChatIDs.insert(chat.objectID).inserted { + selectedChatIDs.remove(chat.objectID) + } + } + + private func handle(_ action: ChatListRowAction, for chat: ChatEntity) { + switch action { + case .beginSelection: + isSelectionMode = true + selectedChatIDs.insert(chat.objectID) + case .togglePin: + setPinned([chat], to: !chat.isPinned) + case .rename: + rename(chat) + case .regenerateName: + nameGenerator.generateNames(for: [chat], in: viewContext) + case .clear: + confirmClear([chat]) + case .export(let format): + ChatExporter.exportWithSavePanel(chat: chat, format: format) + case .delete: + confirmDelete([chat]) + } + } + + private func setPinned(_ targetChats: [ChatEntity], to isPinned: Bool) { + guard !targetChats.isEmpty else { return } + targetChats.forEach { $0.isPinned = isPinned } + saveChanges(action: isPinned ? "pin chats" : "unpin chats") + } + + private func confirmClear(_ targetChats: [ChatEntity]) { + guard !targetChats.isEmpty else { return } + let alert = NSAlert() + alert.messageText = targetChats.count == 1 + ? "Clear \(targetChats[0].name.isEmpty ? "this chat" : targetChats[0].name)?" + : "Clear \(targetChats.count) chats?" + alert.informativeText = "All messages will be deleted. Chat settings will be kept. This action cannot be undone." + alert.addButton(withTitle: "Clear") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.presentAsSheetOrModal { response in + guard response == .alertFirstButtonReturn else { return } + targetChats.filter { !$0.isDeleted }.forEach { $0.clearMessages() } + saveChanges(action: "clear chats") + } + } + + private func confirmDelete(_ targetChats: [ChatEntity]) { + guard !targetChats.isEmpty else { return } + let alert = NSAlert() + alert.messageText = targetChats.count == 1 + ? "Delete \(targetChats[0].name.isEmpty ? "this chat" : targetChats[0].name)?" + : "Delete \(targetChats.count) chats?" + alert.informativeText = "This action cannot be undone." + alert.addButton(withTitle: "Delete") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.presentAsSheetOrModal { response in + guard response == .alertFirstButtonReturn else { return } + let targetIDs = Set(targetChats.map(\.objectID)) + if let selectedChat, targetIDs.contains(selectedChat.objectID) { + self.selectedChat = nil + } + targetChats.filter { !$0.isDeleted }.forEach(viewContext.delete) + if saveChanges(action: "delete chats") { + selectedChatIDs.subtract(targetIDs) + if selectedChatIDs.isEmpty { + isSelectionMode = false + } + } + } + } + + private func rename(_ chat: ChatEntity) { + let alert = NSAlert() + alert.messageText = "Rename chat" + alert.informativeText = "Enter a new name for this chat." + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24)) + textField.stringValue = chat.name + alert.accessoryView = textField + alert.presentAsSheetOrModal { response in + guard response == .alertFirstButtonReturn else { return } + chat.name = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + saveChanges(action: "rename chat") + } + } + + private func exportSelected(as format: ChatExportFormat) { + ChatExporter.exportWithSavePanel(chats: selectedChats, format: format) + } + + @discardableResult + private func saveChanges(action: String) -> Bool { + do { + try viewContext.save() + return true + } + catch { + presentError("Could not \(action): \(error.localizedDescription)") + return false + } + } + + private func presentError(_ message: String) { + let alert = NSAlert() + alert.messageText = "Chat Action Failed" + alert.informativeText = message + alert.alertStyle = .warning + alert.presentAsSheetOrModal { _ in } + } +} + +@MainActor +private final class ChatNameGenerationCoordinator: ObservableObject { + @Published var isRunning = false + @Published var failureMessage: String? + + private var queue: [ChatEntity] = [] + private var currentViewModel: ChatViewModel? + private var failureCount = 0 + + func generateNames(for chats: [ChatEntity], in context: NSManagedObjectContext) { + guard !isRunning else { return } + queue = chats.filter { !$0.isDeleted && !$0.messagesArray.isEmpty } + guard !queue.isEmpty else { + failureMessage = "Selected chats do not contain messages to name." + return + } + failureCount = 0 + isRunning = true + generateNext(in: context) + } + + private func generateNext(in context: NSManagedObjectContext) { + guard !queue.isEmpty else { + currentViewModel = nil + isRunning = false + if failureCount > 0 { + failureMessage = "Could not generate names for \(failureCount) chat\(failureCount == 1 ? "" : "s")." + } + return + } + + let chat = queue.removeFirst() + guard !chat.isDeleted else { + generateNext(in: context) + return + } + let viewModel = ChatViewModel(chat: chat, viewContext: context, loadsMessages: false) + currentViewModel = viewModel + viewModel.regenerateChatName { [weak self] result in + guard let self else { return } + if case .failure = result { + self.failureCount += 1 + } + self.generateNext(in: context) + } } } diff --git a/macai/UI/ChatList/MessageCell.swift b/macai/UI/ChatList/MessageCell.swift index da320bf..e22cbcc 100644 --- a/macai/UI/ChatList/MessageCell.swift +++ b/macai/UI/ChatList/MessageCell.swift @@ -14,7 +14,9 @@ struct MessageCell: View, Equatable { lhs.timestamp == rhs.timestamp && lhs.message == rhs.message && lhs.showsAttentionIndicator == rhs.showsAttentionIndicator && - lhs.$isActive.wrappedValue == rhs.$isActive.wrappedValue && + lhs.isActive == rhs.isActive && + lhs.isSelectionMode == rhs.isSelectionMode && + lhs.isBulkSelected == rhs.isBulkSelected && lhs.isPinned == rhs.isPinned && lhs.searchText == rhs.searchText } @@ -22,12 +24,15 @@ struct MessageCell: View, Equatable { let chatObjectID: NSManagedObjectID let personaName: String? let chatName: String - @State var timestamp: Date + let timestamp: Date var message: String let showsAttentionIndicator: Bool let isPinned: Bool - @Binding var isActive: Bool + let isActive: Bool + let isSelectionMode: Bool + let isBulkSelected: Bool let searchText: String + let onActivate: () -> Void @State private var isHovered = false @Environment(\.colorScheme) var colorScheme @@ -48,7 +53,7 @@ struct MessageCell: View, Equatable { var body: some View { Button { - isActive = true + onActivate() } label: { HStack { VStack(alignment: .leading, spacing: 4) { @@ -97,9 +102,10 @@ struct MessageCell: View, Equatable { } } .padding(.trailing, 8) + .opacity(isSelectionMode ? 0 : 1) } .frame(maxWidth: .infinity) - .foregroundColor(self.isActive ? .white : .primary) + .foregroundStyle(self.isActive ? Color.white : Color.primary) .background( RoundedRectangle(cornerRadius: 8) .fill( @@ -109,12 +115,27 @@ struct MessageCell: View, Equatable { ? (colorScheme == .dark ? Color(hex: "#666666")! : Color(hex: "#CCCCCC")!) : Color.clear ) ) + .overlay(alignment: .trailing) { + Image(systemName: isBulkSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle( + isActive + ? Color.white + : isBulkSelected ? Color.accentColor : Color.secondary + ) + .padding(.trailing, 8) + .opacity(isSelectionMode ? 1 : 0) + .accessibilityHidden(true) + } .onHover { hovering in isHovered = hovering } } .buttonStyle(.borderless) .background(Color.clear) + .accessibilityLabel(chatName.isEmpty ? "Untitled Chat" : chatName) + .accessibilityValue(isSelectionMode ? (isBulkSelected ? "Selected" : "Not selected") : "") + .accessibilityAddTraits(isSelectionMode && isBulkSelected ? .isSelected : []) } } @@ -164,8 +185,11 @@ struct MessageCell_Previews: PreviewProvider { message: message, showsAttentionIndicator: showsAttentionIndicator, isPinned: isPinned, - isActive: .constant(isActive), - searchText: "" + isActive: isActive, + isSelectionMode: false, + isBulkSelected: false, + searchText: "", + onActivate: {} ) } diff --git a/macai/UI/Components/ButtonWithStatusIndicator.swift b/macai/UI/Components/ButtonWithStatusIndicator.swift index a210aab..893e5c0 100644 --- a/macai/UI/Components/ButtonWithStatusIndicator.swift +++ b/macai/UI/Components/ButtonWithStatusIndicator.swift @@ -8,6 +8,7 @@ struct ButtonWithStatusIndicator: View { let errorMessage: String? let successMessage: String let isSuccess: Bool + var isWarning: Bool = false @State private var loadingIconIndex = 0 private let loadingIcons = ["play.fill"] @@ -46,6 +47,8 @@ struct ButtonWithStatusIndicator: View { return .yellow } else if hasError { return .red + } else if isWarning { + return .orange } else if isSuccess { return .green } diff --git a/macai/UI/Components/MacaiTextField.swift b/macai/UI/Components/MacaiTextField.swift index 99f6e0e..59447a3 100644 --- a/macai/UI/Components/MacaiTextField.swift +++ b/macai/UI/Components/MacaiTextField.swift @@ -11,6 +11,12 @@ import SwiftUI import AppKit import UniformTypeIdentifiers +struct PastedPDF { + let url: URL? + let data: Data? + let filename: String +} + struct MacaiTextField: View { var title: String @Binding var text: String @@ -23,6 +29,11 @@ struct MacaiTextField: View { var onTab: (() -> Void)? var onBackTab: (() -> Void)? var onEscape: (() -> Void)? + var onMoveUp: (() -> Void)? + var onMoveDown: (() -> Void)? + var onPasteImage: ((NSImage) -> Void)? + var onPastePDF: ((PastedPDF) -> Void)? + var handlesPasteAttachments: Bool @State private var measuredHeight: CGFloat @State private var placeholderHeight: CGFloat = 0 @@ -38,6 +49,11 @@ struct MacaiTextField: View { onTab: (() -> Void)? = nil, onBackTab: (() -> Void)? = nil, onEscape: (() -> Void)? = nil, + onMoveUp: (() -> Void)? = nil, + onMoveDown: (() -> Void)? = nil, + onPasteImage: ((NSImage) -> Void)? = nil, + onPastePDF: ((PastedPDF) -> Void)? = nil, + handlesPasteAttachments: Bool = false, onCommit: (() -> Void)? = nil ) { self.title = String(title) @@ -51,6 +67,11 @@ struct MacaiTextField: View { self.onTab = onTab self.onBackTab = onBackTab self.onEscape = onEscape + self.onMoveUp = onMoveUp + self.onMoveDown = onMoveDown + self.onPasteImage = onPasteImage + self.onPastePDF = onPastePDF + self.handlesPasteAttachments = handlesPasteAttachments _measuredHeight = State(initialValue: minHeight) } @@ -84,7 +105,12 @@ struct MacaiTextField: View { onCommit: onCommit, onTab: onTab, onBackTab: onBackTab, - onEscape: onEscape + onEscape: onEscape, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, + onPasteImage: onPasteImage, + onPastePDF: onPastePDF, + handlesPasteAttachments: handlesPasteAttachments ) } .frame(height: min(max(currentHeight, minHeight), maxHeight)) @@ -121,6 +147,11 @@ private struct MacaiTextFieldRep: NSViewRepresentable { var onTab: (() -> Void)? var onBackTab: (() -> Void)? var onEscape: (() -> Void)? + var onMoveUp: (() -> Void)? + var onMoveDown: (() -> Void)? + var onPasteImage: ((NSImage) -> Void)? + var onPastePDF: ((PastedPDF) -> Void)? + var handlesPasteAttachments: Bool func makeCoordinator() -> Coordinator { Coordinator(self) @@ -129,6 +160,9 @@ private struct MacaiTextFieldRep: NSViewRepresentable { func makeNSView(context: Context) -> NSScrollView { let textView = makeTextView() textView.focusBinding = isFocused + textView.onPasteImage = onPasteImage + textView.onPastePDF = onPastePDF + textView.handlesPasteAttachments = handlesPasteAttachments textView.delegate = context.coordinator textView.string = text if let fontSize { @@ -158,6 +192,9 @@ private struct MacaiTextFieldRep: NSViewRepresentable { guard let textView = scrollView.documentView as? MacaiNSTextView else { return } textView.focusBinding = isFocused + textView.onPasteImage = onPasteImage + textView.onPastePDF = onPastePDF + textView.handlesPasteAttachments = handlesPasteAttachments if textView.string != text, !textView.hasMarkedText() { let selectedRange = textView.selectedRange() @@ -274,6 +311,16 @@ private struct MacaiTextFieldRep: NSViewRepresentable { { onEscape() return true + } else if let onMoveUp = parent.onMoveUp, + commandSelector == #selector(NSResponder.moveUp(_:)) + { + onMoveUp() + return true + } else if let onMoveDown = parent.onMoveDown, + commandSelector == #selector(NSResponder.moveDown(_:)) + { + onMoveDown() + return true } return false @@ -288,6 +335,9 @@ private struct MacaiTextFieldRep: NSViewRepresentable { private final class MacaiNSTextView: NSTextView { var focusBinding: Binding? var onSizeChange: (() -> Void)? + var onPasteImage: ((NSImage) -> Void)? + var onPastePDF: ((PastedPDF) -> Void)? + var handlesPasteAttachments = false private var isUpdatingSize = false override func becomeFirstResponder() -> Bool { @@ -300,6 +350,71 @@ private final class MacaiNSTextView: NSTextView { return super.resignFirstResponder() } + override func paste(_ sender: Any?) { + let pasteboard = NSPasteboard.general + + if let file = pastedFile(from: pasteboard) { + if file.type.conforms(to: .pdf), let onPastePDF { + onPastePDF(PastedPDF(url: file.url, data: nil, filename: file.url.lastPathComponent)) + } else if file.type.conforms(to: .image), + let onPasteImage, + let image = NSImage(contentsOf: file.url) + { + onPasteImage(image) + } else { + NSSound.beep() + } + return + } + + if pasteboard.availableType(from: [.fileURL]) != nil { + NSSound.beep() + return + } + + if let pdfData = pasteboard.data(forType: .pdf) { + if let onPastePDF { + onPastePDF(PastedPDF(url: nil, data: pdfData, filename: "Pasted Document.pdf")) + } else if handlesPasteAttachments { + NSSound.beep() + } + return + } + + if let image = pastedBitmap(from: pasteboard) { + if let onPasteImage { + onPasteImage(image) + } else if handlesPasteAttachments { + NSSound.beep() + } + return + } + super.paste(sender) + } + + private func pastedFile(from pasteboard: NSPasteboard) -> (url: URL, type: UTType)? { + let fileObjects = pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [NSURL] ?? [] + + guard fileObjects.count == 1 else { return nil } + let url = fileObjects[0] as URL + let type = UTType(filenameExtension: url.pathExtension) ?? .data + return (url, type) + } + + private func pastedBitmap(from pasteboard: NSPasteboard) -> NSImage? { + for type in [NSPasteboard.PasteboardType.png, .tiff] { + if let data = pasteboard.data(forType: type), + let image = NSImage(data: data) + { + return image + } + } + return nil + } + override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) guard window != nil else { return } diff --git a/macai/UI/ContentView.swift b/macai/UI/ContentView.swift index a7b7f08..e0809ff 100644 --- a/macai/UI/ContentView.swift +++ b/macai/UI/ContentView.swift @@ -18,6 +18,7 @@ struct ContentView: View { @State private var window: NSWindow? @Environment(\.scenePhase) private var scenePhase @Environment(\.managedObjectContext) private var viewContext + @Environment(\.openSettings) private var openSettings @FetchRequest( entity: ChatEntity.entity(), @@ -29,12 +30,13 @@ struct ContentView: View { private var apiServices: FetchedResults @State var selectedChat: ChatEntity? - @AppStorage("gptToken") var gptToken = "" - @AppStorage("gptModel") var gptModel = AppConstants.defaultPrimaryModel - @AppStorage("systemMessage") var systemMessage = AppConstants.chatGptSystemMessage @AppStorage("lastOpenedChatId") var lastOpenedChatId = "" @AppStorage("apiUrl") var apiUrl = AppConstants.apiUrlOpenAIResponses @AppStorage(SettingsIndicatorKeys.generalSeen) private var generalSettingsSeen: Bool = false + @AppStorage(AppConstants.assistantSettingsMigrationNoticePendingKey) + private var isAssistantMigrationNoticePending = false + @State private var isAssistantMigrationNoticePresented = false + @State private var didEvaluateAssistantMigrationNotice = false @StateObject private var previewStateManager = PreviewStateManager() @StateObject private var attentionStore = ChatAttentionStore.shared @@ -44,92 +46,59 @@ struct ContentView: View { @State private var lastChatCount = 0 @State private var searchText = "" @State private var isSearchPresented = false + @State private var isAssistantPickerPresented = false + @State private var isChatSelectionMode = false + @State private var keyDownMonitor: Any? + @AppStorage("assistantPanelVisible") private var assistantPanelVisible = false + @AppStorage("chatSettingsPanelWidth") private var chatSettingsPanelWidth: Double = 320 + @State private var panelDragStartWidth: Double? + private static let chatSettingsPanelMinWidth: Double = 220 + private static let chatSettingsPanelMaxWidth: Double = 560 + private var isSidebarVisible: Bool { columnVisibility != .detailOnly } var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { - ChatListView(selectedChat: $selectedChat, searchText: $searchText) - .environmentObject(attentionStore) - .navigationSplitViewColumnWidth( - min: 180, - ideal: 220, - max: 400 - ) + primaryColumn } detail: { - HSplitView { - if selectedChat != nil { - ChatView(viewContext: viewContext, chat: selectedChat!, searchText: $searchText) - .frame(minWidth: 400) - .id(openedChatId) - } - else { - WelcomeScreen( - chatsCount: chats.count, - apiServiceIsPresent: apiServices.count > 0, - customUrl: apiUrl != AppConstants.apiUrlOpenAIResponses, - openPreferencesView: openPreferencesView, - newChat: newChat - ) - } - - if previewStateManager.isPreviewVisible { - PreviewPane(stateManager: previewStateManager) - } - } - .searchable(text: $searchText, isPresented: $isSearchPresented, placement: .toolbar, prompt: "Search in chat…") - .onSubmit(of: .search) { - // Handle Enter key in search field - go to next occurrence - NotificationCenter.default.post( - name: NSNotification.Name("FindNext"), - object: nil - ) - } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ActivateSearch"))) { _ in - // Remove focus from any text fields - NSApp.keyWindow?.makeFirstResponder(nil) - isSearchPresented = true - } - .onAppear { - // Add global key monitor for Shift+Enter when search is active - NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - if event.keyCode == 36 && event.modifierFlags.contains(.shift) && isSearchPresented && !searchText.isEmpty { - // Check if search field is focused by checking if any search-related view is first responder - if let firstResponder = NSApp.keyWindow?.firstResponder as? NSView, - String(describing: type(of: firstResponder)).contains("Search") { - NotificationCenter.default.post( - name: NSNotification.Name("FindPrevious"), - object: nil - ) - return nil - } - } - - // Cmd+Shift+Delete -> Clear currently selected chat - // keyCode 51 corresponds to the Delete (backspace) key on macOS keyboards - if event.keyCode == 51 && event.modifierFlags.contains(.command) && event.modifierFlags.contains(.shift) { - if selectedChat != nil { - clearSelectedChat() - return nil - } - } - - return event - } - } + detailColumn } .onAppear(perform: { updateSidebarVisibilityForChatCount(previousCount: lastChatCount, newCount: chats.count) lastChatCount = chats.count + if !didEvaluateAssistantMigrationNotice { + didEvaluateAssistantMigrationNotice = true + isAssistantMigrationNoticePresented = isAssistantMigrationNoticePending + } if let lastOpenedChatId = UUID(uuidString: lastOpenedChatId) { if let lastOpenedChat = chats.first(where: { $0.id == lastOpenedChatId }) { selectedChat = lastOpenedChat } } }) - .onChange(of: chats.count) { newCount in + .onChange(of: chats.count) { _, newCount in updateSidebarVisibilityForChatCount(previousCount: lastChatCount, newCount: newCount) lastChatCount = newCount } .background(WindowAccessor(window: $window)) + .onChange(of: window) { _, _ in + scheduleToolbarReorder() + } + .onChange(of: columnVisibility) { _, _ in + // Toolbar contents rebuild when the sidebar toggles; re-assert order. + scheduleToolbarReorder() + } + .onChange(of: isSearchPresented) { _, isPresented in + if isPresented { + // The system search item is inserted asynchronously when the + // modifier appears, so re-apply the toolbar order afterwards. + scheduleToolbarReorder() + } + else { + // A collapsed search should leave the chat unfiltered and show + // only the magnifying-glass button again. + searchText = "" + } + } .onAppear { NotificationCenter.default.addObserver( forName: AppConstants.newChatNotification, @@ -178,70 +147,67 @@ struct ContentView: View { } } .navigationTitle("Chats") + .alert("Review your v3 settings", isPresented: $isAssistantMigrationNoticePresented) { + Button("Open Settings") { + isAssistantMigrationNoticePending = false + UserDefaults.standard.set(1, forKey: AppConstants.selectedPreferencesTabKey) + openPreferencesView() + } + Button("Later", role: .cancel) {} + } message: { + Text( + "API Services now only define how macai connects to an API. " + + "Models, system instructions, and chat behavior have moved to AI Assistants. " + + "Your existing settings were migrated automatically, but we recommend reviewing both your " + + "API Services and AI Assistants." + ) + } .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - if let selectedChatType = selectedChat?.apiService?.type { - Image("logo_\(selectedChatType)") - .resizable() - .renderingMode(.template) - .interpolation(.high) - .frame(width: 16, height: 16) - .padding(.horizontal, 12) - } - - if let selectedChat = selectedChat { - Menu { - ForEach(apiServices, id: \.objectID) { apiService in - Button(action: { - selectedChat.apiService = apiService - handleServiceChange(selectedChat, apiService) - }) { - HStack { - Text(apiService.name ?? "Unnamed API Service") - if selectedChat.apiService == apiService { - Image(systemName: "checkmark") - } - } - } - } - - Divider() - - Text("Current Model: \(selectedChat.gptModel)") - .foregroundColor(.secondary) - } label: { - Text(selectedChat.apiService?.name ?? "Select API Service") + if !isSidebarVisible { + ToolbarItem(placement: .navigation) { + Button(action: { + newChat() + }) { + Label("New Chat", systemImage: "square.and.pencil") } + .labelStyle(.iconOnly) } - - Button(action: { - newChat() - }) { - Image(systemName: "square.and.pencil") - } + } - if #available(macOS 14.0, *) { - SettingsLink { - settingsGearIcon - } - } - else { + if !isSearchPresented { + ToolbarItem(placement: .primaryAction) { Button(action: { - openPreferencesView() + isSearchPresented = true }) { - settingsGearIcon + Label("Find in Chat", systemImage: "magnifyingglass") } + .labelStyle(.iconOnly) + .help("Find in Chat (⌘F)") } } - } - .onChange(of: scenePhase) { phase in + // A single persistent toggle: toolbar items contributed by inspector + // content linger after dismissal (stale duplicate button), so the + // button stays in the main toolbar in both states. + ToolbarItem(placement: .primaryAction) { + Button(action: { + assistantPanelVisible.toggle() + }) { + // The label doubles as the NSToolbar identity used by + // moveChatSettingsToggleAfterSearch — keep them in sync. + Label(ContentView.chatSettingsToolbarLabel, systemImage: "sidebar.trailing") + } + .labelStyle(.iconOnly) + .help(assistantPanelVisible ? "Hide chat settings" : "Show chat settings") + } + } + .onChange(of: scenePhase) { _, phase in print("Scene phase changed: \(phase)") if phase == .inactive { print("Saving state...") } } - .onChange(of: selectedChat) { newValue in + .onChange(of: selectedChat) { _, newValue in if self.openedChatId != newValue?.id.uuidString { self.openedChatId = newValue?.id.uuidString previewStateManager.hidePreview() @@ -288,6 +254,24 @@ struct ContentView: View { clearSelectedChat() } } + .onReceive(NotificationCenter.default.publisher(for: AppConstants.exportChatNotification)) { notification in + guard let chat = selectedChat else { return } + let rawFormat = notification.userInfo?["format"] as? String + let format = rawFormat.flatMap(ChatExportFormat.init(rawValue:)) ?? .markdown + ChatExporter.exportWithSavePanel(chat: chat, format: format) + } + .onReceive(NotificationCenter.default.publisher(for: AppConstants.selectAssistantNotification)) { notification in + let sourceWindowId = notification.userInfo?["windowId"] as? Int + guard sourceWindowId == nil || sourceWindowId == 0 || sourceWindowId == window?.windowNumber else { return } + isAssistantPickerPresented = true + } + .sheet(isPresented: $isAssistantPickerPresented) { + AssistantQuickPicker { assistant in + newChat(using: assistant) + } + .environment(\.managedObjectContext, viewContext) + } + .focusedSceneValue(\.chatIsSelected, selectedChat != nil) .environmentObject(previewStateManager) } @@ -306,10 +290,18 @@ struct ContentView: View { } private func newChat(using preferredService: APIServiceEntity?) { - let uuid = UUID() - let newChat = ChatEntity(context: viewContext) + guard let assistant = resolveAssistant(for: preferredService) else { + UserDefaults.standard.set(2, forKey: AppConstants.selectedPreferencesTabKey) + openPreferencesView() + return + } + newChat(using: assistant) + } - newChat.id = uuid + private func newChat(using assistant: PersonaEntity) { + guard let service = assistant.apiService else { return } + let newChat = ChatEntity(context: viewContext) + newChat.id = UUID() newChat.newChat = true newChat.temperature = 1 newChat.top_p = 1.0 @@ -317,31 +309,19 @@ struct ContentView: View { newChat.draftMessage = "" newChat.createdDate = Date() newChat.updatedDate = Date() - newChat.systemMessage = systemMessage - newChat.gptModel = gptModel + newChat.systemMessage = assistant.systemMessage ?? AppConstants.chatGptSystemMessage + let model = assistant.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + newChat.gptModel = model.isEmpty ? AppConstants.defaultModel(for: service.type) : model newChat.lastSequence = 0 - - if let service = preferredService { - newChat.apiService = service - newChat.persona = service.defaultPersona - newChat.gptModel = service.model ?? AppConstants.defaultModel(for: service.type) - newChat.systemMessage = service.defaultPersona?.systemMessage ?? AppConstants.chatGptSystemMessage - } - else if let defaultService = resolveDefaultAPIService() { - newChat.apiService = defaultService - newChat.persona = defaultService.defaultPersona - // TODO: Refactor the following code along with ChatView.swift - newChat.gptModel = defaultService.model ?? AppConstants.defaultModel(for: defaultService.type) - newChat.systemMessage = newChat.persona?.systemMessage ?? AppConstants.chatGptSystemMessage - } + newChat.persona = assistant + newChat.apiService = service do { try viewContext.save() selectedChat = newChat - } - catch { - print("Error saving new chat: \(error.localizedDescription)") + } catch { viewContext.rollback() + print("Error saving new chat: \(error.localizedDescription)") } } @@ -361,32 +341,12 @@ struct ContentView: View { } } - private func resolveDefaultAPIService() -> APIServiceEntity? { - if let service = apiServices.first(where: { $0.isDefault }) { - return service - } - - if let defaultServiceIDString = UserDefaults.standard.string(forKey: "defaultApiService"), - let url = URL(string: defaultServiceIDString), - let objectID = viewContext.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: url), - let service = try? viewContext.existingObject(with: objectID) as? APIServiceEntity - { - service.isDefault = true - viewContext.saveWithRetry(attempts: 1) - UserDefaults.standard.removeObject(forKey: "defaultApiService") - return service - } - - return nil + private func resolveAssistant(for preferredService: APIServiceEntity?) -> PersonaEntity? { + try? PersonaEntity.preferredAssistant(in: viewContext, for: preferredService) } func openPreferencesView() { - if #available(macOS 13.0, *) { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - } - else { - NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) - } + openSettings() } func clearSelectedChat() { @@ -397,7 +357,7 @@ struct ContentView: View { alert.addButton(withTitle: "Clear") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning - alert.beginSheetModal(for: NSApp.keyWindow!) { response in + alert.presentAsSheetOrModal { response in if response == .alertFirstButtonReturn { chat.clearMessages() do { @@ -409,6 +369,279 @@ struct ContentView: View { } } + private var primaryColumn: some View { + ChatListView( + selectedChat: $selectedChat, + searchText: $searchText, + isSelectionMode: $isChatSelectionMode + ) + .environmentObject(attentionStore) + .navigationSplitViewColumnWidth( + min: 180, + ideal: 220, + max: 400 + ) + .toolbar { + if isSidebarVisible { + ToolbarItem(placement: .navigation) { + Button(action: { + newChat() + }) { + Label("New Chat", systemImage: "square.and.pencil") + } + .labelStyle(.iconOnly) + .help("New Chat") + } + + } + } + } + + @ViewBuilder + private var detailColumn: some View { + HStack(spacing: 0) { + chatContent + + if assistantPanelVisible { + chatSettingsPanel + .transition(.move(edge: .trailing)) + } + } + .animation(.easeInOut(duration: 0.2), value: assistantPanelVisible) + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ActivateSearch"))) { _ in + // Remove focus from any text fields + NSApp.keyWindow?.makeFirstResponder(nil) + isSearchPresented = true + } + .onAppear { + // Install the key monitor once; re-appearing must not stack duplicates. + guard keyDownMonitor == nil else { return } + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if event.keyCode == 36 && event.modifierFlags.contains(.shift) && isSearchPresented && !searchText.isEmpty { + // Check if search field is focused by checking if any search-related view is first responder + if let firstResponder = NSApp.keyWindow?.firstResponder as? NSView, + String(describing: type(of: firstResponder)).contains("Search") { + NotificationCenter.default.post( + name: NSNotification.Name("FindPrevious"), + object: nil + ) + return nil + } + } + + // Cmd+Shift+Delete -> Clear currently selected chat + // keyCode 51 corresponds to the Delete (backspace) key on macOS keyboards + if event.keyCode == 51 && event.modifierFlags.contains(.command) && event.modifierFlags.contains(.shift) { + if selectedChat != nil { + clearSelectedChat() + return nil + } + } + + return event + } + } + } + + @ViewBuilder + private var chatContent: some View { + // The searchable modifier lives on an invisible background anchor, not + // on the chat itself: wrapping chatContentBase in a conditional would + // change its view identity on every search toggle and recreate the + // whole ChatView (message reload, scroll reset, broken Stop while + // streaming). Toggling the anchor only recreates a Color.clear. + chatContentBase + .background { + if isSearchPresented { + Color.clear + .searchable( + text: $searchText, + isPresented: $isSearchPresented, + placement: .toolbar, + prompt: "Search" + ) + .onSubmit(of: .search) { + if !searchText.isEmpty { + NotificationCenter.default.post( + name: NSNotification.Name("FindNext"), + object: nil + ) + } + } + } + } + } + + @ViewBuilder + private var chatContentBase: some View { + HSplitView { + if let selectedChat { + // GeometryReader is load-bearing: it is the only container + // whose reported minimum width is truly 0, cutting off the + // large natural minimums of the chat content (text views, + // input bar) so they never inflate the window's minimum + // size. The outer frame then sets the real floor. + GeometryReader { _ in + ChatView( + viewContext: viewContext, + chat: selectedChat, + searchText: $searchText, + isSearchPresented: $isSearchPresented + ) + } + .frame(minWidth: 150, maxWidth: .infinity) + .id(openedChatId) + } else { + WelcomeScreen( + chatsCount: chats.count, + apiServiceIsPresent: apiServices.count > 0, + customUrl: apiUrl != AppConstants.apiUrlOpenAIResponses, + openPreferencesView: openPreferencesView, + newChat: newChat + ) + } + + if previewStateManager.isPreviewVisible { + PreviewPane(stateManager: previewStateManager) + } + } + } + + /// SwiftUI always renders the `.searchable` toolbar field as the + /// trailing-most item, but the Chat Settings toggle belongs at the trailing + /// edge, right above the panel it controls (Xcode-style). SwiftUI offers no + /// ordering control here, so reorder the NSToolbar directly: move our + /// toggle (the only custom item after the split-view separator) behind the + /// search field. + /// The toolbar is populated asynchronously after the window appears and + /// after SwiftUI toolbar updates; retry a few times. The reorder itself is + /// idempotent (no-op once the toggle sits behind the search field). + private func scheduleToolbarReorder() { + for delay in [0.2, 1.0, 2.5] { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + moveChatSettingsToggleAfterSearch() + } + } + } + + /// Reacts to SwiftUI inserting/removing toolbar items (e.g. the search + /// field appearing when search is activated) and re-asserts the order + /// within the same runloop turn — before the next frame is drawn — so the + /// wrong order is never visible. The timer-based retries above remain as a + /// startup fallback only. + private func installToolbarObserverIfNeeded(_ toolbar: NSToolbar) { + guard ContentView.observedToolbars.contains(ObjectIdentifier(toolbar)) == false else { return } + ContentView.observedToolbars.insert(ObjectIdentifier(toolbar)) + + let reorder: (Notification) -> Void = { [weak toolbar] _ in + DispatchQueue.main.async { + guard let toolbar else { return } + Self.moveChatSettingsToggleAfterSearch(in: toolbar) + } + } + NotificationCenter.default.addObserver( + forName: NSToolbar.willAddItemNotification, object: toolbar, queue: .main, using: reorder + ) + NotificationCenter.default.addObserver( + forName: NSToolbar.didRemoveItemNotification, object: toolbar, queue: .main, using: reorder + ) + } + + private static var observedToolbars = Set() + + private func moveChatSettingsToggleAfterSearch() { + guard let toolbar = window?.toolbar else { return } + installToolbarObserverIfNeeded(toolbar) + Self.moveChatSettingsToggleAfterSearch(in: toolbar) + } + + static let chatSettingsToolbarLabel = "Chat Settings" + + private static func moveChatSettingsToggleAfterSearch(in toolbar: NSToolbar) { + // Identified by label: SwiftUI assigns random UUID identifiers to its + // toolbar items, and position heuristics break when the search field + // or the magnifier button get re-inserted at the end. + guard let buttonIndex = toolbar.items.lastIndex(where: { $0.label == chatSettingsToolbarLabel }) else { + return + } + guard buttonIndex != toolbar.items.count - 1 else { return } + + let buttonID = toolbar.items[buttonIndex].itemIdentifier + toolbar.removeItem(at: buttonIndex) + toolbar.insertItem(withItemIdentifier: buttonID, at: toolbar.items.count) + } + + /// Custom trailing panel: the native `.inspector` freezes the window's + /// minimum size at its current width on macOS 26 (verified empirically in + /// every configuration), and an HSplitView pane cannot animate. A plain + /// HStack with a draggable edge gives inspector-like behavior with none of + /// the window-sizing side effects. + private var chatSettingsPanel: some View { + HStack(spacing: 0) { + panelResizeHandle + + Group { + if let selectedChat { + ChatSettingsView( + chat: selectedChat, + onServiceChange: { service in + handleServiceChange(selectedChat, service) + } + ) + } else { + VStack { + Text("Select a chat to see its settings.") + .foregroundStyle(.secondary) + .padding() + Spacer() + } + } + } + // min lets the window squeeze the panel; max caps it at the width + // the user chose by dragging the handle. + .frame( + minWidth: Self.chatSettingsPanelMinWidth, + idealWidth: chatSettingsPanelWidth, + maxWidth: chatSettingsPanelWidth + ) + } + .background(Color(NSColor.windowBackgroundColor)) + } + + private var panelResizeHandle: some View { + Rectangle() + .fill(Color(NSColor.separatorColor)) + .frame(width: 1) + .overlay( + // Invisible widened hit area for comfortable dragging. + Color.clear + .frame(width: 9) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 1, coordinateSpace: .global) + .onChanged { value in + let start = panelDragStartWidth ?? chatSettingsPanelWidth + panelDragStartWidth = start + let proposed = start - value.translation.width + chatSettingsPanelWidth = min( + max(proposed, Self.chatSettingsPanelMinWidth), + Self.chatSettingsPanelMaxWidth + ) + } + .onEnded { _ in + panelDragStartWidth = nil + } + ) + .onHover { hovering in + if hovering { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.pop() + } + } + ) + } + private func getIndex(for chat: ChatEntity) -> Int { if let index = chats.firstIndex(where: { $0.id == chat.id }) { return index @@ -419,19 +652,22 @@ struct ContentView: View { } private func handleServiceChange(_ chat: ChatEntity, _ newService: APIServiceEntity) { - if chat.messagesArray.isEmpty { - if let newDefaultPersona = newService.defaultPersona { - chat.persona = newDefaultPersona - if let newSystemMessage = chat.persona?.systemMessage, - !newSystemMessage.isEmpty - { - chat.systemMessage = newSystemMessage - } - } - } - + // Per-chat setting: only this chat switches service; the assistant + // (and every other chat using it) is untouched. + let currentModel = chat.effectiveModel.trimmingCharacters(in: .whitespacesAndNewlines) chat.apiService = newService - chat.gptModel = newService.model ?? AppConstants.defaultModel(for: newService.type) + chat.apiServiceOverridden = true + + // Keep the chat's model only when we know the new service offers it; + // otherwise fall back to the service default. + let knownModels = newService.id.flatMap { ModelsCache.shared.models(for: $0) } ?? [] + if currentModel.isEmpty || !knownModels.contains(currentModel) { + chat.gptModel = AppConstants.defaultModel(for: newService.type) + } else { + chat.gptModel = currentModel + } + chat.modelOverridden = true + chat.objectWillChange.send() try? viewContext.save() @@ -492,6 +728,80 @@ private extension ContentView { } +private struct AssistantQuickPicker: View { + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \PersonaEntity.order, ascending: true)], + predicate: NSPredicate(format: "apiService != nil"), + animation: .default + ) + private var assistants: FetchedResults + @Environment(\.dismiss) private var dismiss + @State private var query = "" + @State private var selection: NSManagedObjectID? + @FocusState private var searchIsFocused: Bool + let onSelect: (PersonaEntity) -> Void + + private var filteredAssistants: [PersonaEntity] { + guard !query.isEmpty else { return Array(assistants) } + return assistants.filter { ($0.name ?? "").localizedCaseInsensitiveContains(query) } + } + + var body: some View { + VStack(spacing: 12) { + TextField("Search assistants", text: $query) + .textFieldStyle(.roundedBorder) + .focused($searchIsFocused) + + List(filteredAssistants, selection: $selection) { assistant in + HStack { + Circle() + .fill(Color(hex: assistant.color ?? AppConstants.defaultPersonaColor) ?? .accentColor) + .frame(width: 10, height: 10) + Text(assistant.name ?? "Untitled Assistant") + Spacer() + Text(assistant.apiService?.name ?? "") + .foregroundStyle(.secondary) + } + .tag(assistant.objectID) + .contentShape(.rect) + .onTapGesture(count: 2) { choose(assistant) } + } + .frame(minHeight: 220) + + HStack { + Spacer() + Button("Cancel") { dismiss() } + Button("Create Chat") { chooseSelected() } + .keyboardShortcut(.defaultAction) + .disabled(selectedAssistant == nil) + } + } + .padding(16) + .frame(width: 440) + .onAppear { + selection = filteredAssistants.first?.objectID + searchIsFocused = true + } + .onChange(of: query) { _, _ in + selection = filteredAssistants.first?.objectID + } + } + + private var selectedAssistant: PersonaEntity? { + filteredAssistants.first { $0.objectID == selection } + } + + private func chooseSelected() { + guard let selectedAssistant else { return } + choose(selectedAssistant) + } + + private func choose(_ assistant: PersonaEntity) { + onSelect(assistant) + dismiss() + } +} + struct PreviewPane: View { @ObservedObject var stateManager: PreviewStateManager @State private var isResizing = false diff --git a/macai/UI/Preferences/PreferencesView.swift b/macai/UI/Preferences/PreferencesView.swift index dd37ce9..3108830 100644 --- a/macai/UI/Preferences/PreferencesView.swift +++ b/macai/UI/Preferences/PreferencesView.swift @@ -20,35 +20,25 @@ struct APIRequestData: Codable { struct PreferencesView: View { @StateObject private var store = ChatStore(persistenceController: PersistenceController.shared) - @State private var lampColor: Color = .gray + @AppStorage(AppConstants.selectedPreferencesTabKey) private var selectedTab = 0 var body: some View { - TabView { - - TabGeneralSettingsView() - .tabItem { - Label("General", systemImage: "gearshape") - } - - TabAPIServicesView() - .tabItem { - Label("API Services", systemImage: "network") - } - - TabAIPersonasView() - .tabItem { - Label("AI Assistants", systemImage: "person.2") - } - - BackupRestoreView(store: store) - .tabItem { - Label("Backup & Restore", systemImage: "externaldrive") - } - - DangerZoneView(store: store) - .tabItem { - Label("Danger Zone", systemImage: "flame.fill") - } + TabView(selection: $selectedTab) { + Tab("General", systemImage: "gearshape", value: 0) { + TabGeneralSettingsView() + } + Tab("API Services", systemImage: "network", value: 1) { + TabAPIServicesView() + } + Tab("AI Assistants", systemImage: "person.2", value: 2) { + TabAIPersonasView() + } + Tab("Backup & Restore", systemImage: "externaldrive", value: 3) { + BackupRestoreView(store: store) + } + Tab("Danger Zone", systemImage: "flame.fill", value: 4) { + DangerZoneView(store: store) + } } .frame(width: 480) .padding() diff --git a/macai/UI/Preferences/TabAIPersonasView.swift b/macai/UI/Preferences/TabAIPersonasView.swift index fec7ef0..9a4098c 100644 --- a/macai/UI/Preferences/TabAIPersonasView.swift +++ b/macai/UI/Preferences/TabAIPersonasView.swift @@ -7,7 +7,6 @@ import CoreData import SwiftUI -import UniformTypeIdentifiers struct TabAIPersonasView: View { @Environment(\.managedObjectContext) private var viewContext @@ -24,6 +23,10 @@ struct TabAIPersonasView: View { @State private var showingDeleteConfirmation = false @State private var personaToDelete: PersonaEntity? + private var isSelectedPersonaDefault: Bool { + personas.first(where: { $0.objectID == selectedPersonaID })?.isDefault ?? false + } + var body: some View { VStack { entityListView @@ -47,6 +50,13 @@ struct TabAIPersonasView: View { Label("Delete", systemImage: "trash") } .buttonStyle(BorderlessButtonStyle()) + + if !isSelectedPersonaDefault { + Button(action: setSelectedPersonaAsDefault) { + Label("Set as Default", systemImage: "star") + } + .buttonStyle(BorderlessButtonStyle()) + } } Spacer() if personas.count == 0 { @@ -58,10 +68,8 @@ struct TabAIPersonasView: View { } } .frame(minHeight: 300) - .onChange(of: selectedPersonaID) { id in + .onChange(of: selectedPersonaID) { _, id in selectedPersona = personas.first(where: { $0.objectID == id }) - print("Selected Assistant ID: \(id)") - print("Selected Assistant: \(selectedPersona?.name ?? "nil")") } .sheet(isPresented: $isShowingAddOrEditPersona) { PersonaDetailView( @@ -81,6 +89,9 @@ struct TabAIPersonasView: View { primaryButton: .destructive(Text("Delete")) { if let persona = personaToDelete { viewContext.delete(persona) + if persona.isDefault { + personas.first(where: { $0 !== persona && $0.apiService != nil })?.isDefault = true + } try? viewContext.save() selectedPersonaID = nil } @@ -98,6 +109,7 @@ struct TabAIPersonasView: View { onRefresh: refreshList, getEntityColor: getPersonaColor, getEntityName: getPersonaName, + getEntityDefault: { $0.isDefault }, onEdit: { if let persona = personas.first(where: { $0.objectID == selectedPersonaID }) { selectedPersona = persona @@ -165,7 +177,6 @@ struct TabAIPersonasView: View { private func onEdit() { selectedPersona = personas.first(where: { $0.objectID == selectedPersonaID }) isShowingAddOrEditPersona = true - print("Opening Assistant Detail View: \(selectedPersona?.name ?? "nil")") } private func getPersonaColor(persona: PersonaEntity) -> Color? { @@ -179,11 +190,27 @@ struct TabAIPersonasView: View { private func refreshList() { refreshID = UUID() } + + private func setSelectedPersonaAsDefault() { + guard let selected = personas.first(where: { $0.objectID == selectedPersonaID }) else { return } + do { + try PersonaEntity.setDefault(selected, in: viewContext) + try viewContext.save() + } catch { + viewContext.rollback() + print("Failed to set default assistant: \(error)") + } + } } struct PersonaDetailView: View { @Environment(\.managedObjectContext) private var viewContext @Environment(\.presentationMode) var presentationMode + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \APIServiceEntity.addedDate, ascending: false)], + animation: .default + ) + private var apiServices: FetchedResults @Binding var persona: PersonaEntity? let onSave: () -> Void @@ -194,6 +221,36 @@ struct PersonaDetailView: View { @State private var systemMessage: String = "" @State private var showingDeleteConfirmation = false @State private var temperature: Float = AppConstants.defaultPersonaTemperature + @State private var selectedService: APIServiceEntity? + @State private var model: String = "" + @State private var selectedModel: String = "" + @State private var isCustomModel = false + @State private var fetchedModels: [String] = [] + @State private var isLoadingModels = false + @State private var contextSize: Float = Float(AppConstants.chatGptContextSize) + @State private var useStreamResponse = true + @State private var generateChatNames = true + @State private var imageGenerationEnabled = false + @State private var persistenceErrorMessage = "" + @State private var showingPersistenceError = false + + private var serviceConfiguration: AppConstants.defaultApiConfiguration? { + guard let type = selectedService?.type else { return nil } + return AppConstants.defaultApiConfigurations[type] + } + + private var availableModels: [String] { + if fetchedModels.isEmpty == false { + return fetchedModels + } + return serviceConfiguration?.models ?? [] + } + + private var supportsImageGeneration: Bool { + selectedService?.imageGenerationSupported + ?? serviceConfiguration?.imageGenerationSupported + ?? false + } var body: some View { VStack(alignment: .leading) { @@ -247,7 +304,7 @@ struct PersonaDetailView: View { Text("1.0") } - Text(getTemperatureLabel()) + Text(temperature.temperatureDescriptor) .frame(width: 90) } @@ -257,6 +314,114 @@ struct PersonaDetailView: View { } .padding(.top, 8) + Divider() + .padding(.vertical, 8) + + VStack(alignment: .leading, spacing: 10) { + Text("Service & Model") + .font(.headline) + + HStack { + Text("API Service:") + .frame(width: 100, alignment: .leading) + + Picker("API Service", selection: $selectedService) { + Text("None").tag(nil as APIServiceEntity?) + ForEach(apiServices) { service in + Text(service.name ?? "Untitled").tag(service as APIServiceEntity?) + } + } + .labelsHidden() + } + + if selectedService != nil { + HStack { + HStack(spacing: 6) { + Text("Model:") + // Kept in the hierarchy permanently so the row height is stable; + // toggling opacity avoids layout jumps below. + ProgressView() + .controlSize(.small) + .opacity(isLoadingModels ? 1 : 0) + .accessibilityLabel("Loading models") + .accessibilityHidden(!isLoadingModels) + } + .frame(width: 100, alignment: .leading) + + Picker("Model", selection: $selectedModel) { + ForEach(availableModels.sorted(), id: \.self) { modelName in + Text(modelName).tag(modelName) + } + Divider() + Text("Custom Model…").tag("custom") + } + .labelsHidden() + .onChange(of: selectedModel) { _, newValue in + if newValue == "custom" { + isCustomModel = true + } + else { + isCustomModel = false + model = newValue + } + } + } + + if isCustomModel { + TextField("Enter custom model name", text: $model) + .textFieldStyle(RoundedBorderTextFieldStyle()) + } + + HStack { + Slider( + value: $contextSize, + in: 5...100, + step: 5 + ) { + Text("Context size") + } minimumValueLabel: { + Text("5") + } maximumValueLabel: { + Text("100") + } + + Text(String(format: ("%.0f messages"), contextSize)) + .frame(width: 90) + } + } + } + .padding(.top, 4) + + Divider() + .padding(.vertical, 8) + + VStack(alignment: .leading, spacing: 8) { + Text("Behavior") + .font(.headline) + + Toggle(isOn: $generateChatNames) { + Text("Automatically generate chat names") + } + + Toggle(isOn: $useStreamResponse) { + Text("Use streamed responses") + } + } + + Divider() + .padding(.vertical, 8) + + VStack(alignment: .leading, spacing: 8) { + Text("Tools") + .font(.headline) + + Toggle(isOn: $imageGenerationEnabled) { + Text("Image generation") + } + .disabled(!supportsImageGeneration) + .help(supportsImageGeneration ? "" : "This API service does not support image generation.") + } + HStack { if persona != nil { Button(action: { @@ -274,10 +439,16 @@ struct PersonaDetailView: View { } Button(persona == nil ? "Create Assistant" : "Update Assistant") { - savePersona() - onSave() + if savePersona() { + onSave() + } } - .disabled(name.isEmpty || systemMessage.isEmpty) + .disabled( + name.isEmpty + || systemMessage.isEmpty + || selectedService == nil + || model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) .keyboardShortcut(.defaultAction) } .padding(.top, 16) @@ -285,12 +456,48 @@ struct PersonaDetailView: View { } .padding() .onAppear { - print(">> Assistant: \(persona?.name ?? "")") if let persona = persona { name = persona.name ?? "" color = Color(hex: persona.color ?? "#FFFFFF") ?? .white systemMessage = persona.systemMessage ?? "" temperature = persona.temperature + selectedService = persona.apiService + model = persona.model ?? "" + contextSize = Float(persona.contextSize == 0 ? Int16(AppConstants.chatGptContextSize) : persona.contextSize) + useStreamResponse = persona.useStreamResponse + generateChatNames = persona.generateChatNames + imageGenerationEnabled = persona.imageGenerationEnabled + } else { + selectedService = apiServices.first(where: { $0.isDefault }) ?? apiServices.first + if let config = serviceConfiguration { + model = config.defaultModel + selectedModel = config.defaultModel + } + contextSize = Float(AppConstants.chatGptContextSize) + useStreamResponse = true + generateChatNames = true + imageGenerationEnabled = false + } + + syncModelSelection() + refreshModels() + if supportsImageGeneration == false { + imageGenerationEnabled = false + } + } + .onChange(of: selectedService) { _, _ in + if let config = serviceConfiguration { + if model.isEmpty { + model = config.defaultModel + } + if selectedModel.isEmpty { + selectedModel = config.defaultModel + } + } + syncModelSelection() + refreshModels() + if supportsImageGeneration == false { + imageGenerationEnabled = false } } .alert(isPresented: $showingDeleteConfirmation) { @@ -298,40 +505,85 @@ struct PersonaDetailView: View { title: Text("Delete Assistant"), message: Text("Are you sure you want to delete this assistant? This action cannot be undone."), primaryButton: .destructive(Text("Delete")) { - deletePersona() - onDelete() + if deletePersona() { + onDelete() + } }, secondaryButton: .cancel() ) } + .alert("Unable to Save Assistant", isPresented: $showingPersistenceError) { + Button("OK", role: .cancel) {} + } message: { + Text(persistenceErrorMessage) + } } - private func getTemperatureLabel() -> String { - if temperature > 0.8 { - return "Creative" + private func syncModelSelection() { + if availableModels.contains(model) { + selectedModel = model + isCustomModel = false + } else if model.isEmpty == false { + selectedModel = "custom" + isCustomModel = true } + } - if temperature > 0.6 { - return "Explorative" + private func refreshModels() { + guard let service = selectedService else { + fetchedModels = [] + isLoadingModels = false + syncModelSelection() + return } - if temperature > 0.4 { - return "Balanced" + // Show the cached list right away so the selected model is visible + // immediately; a background refresh updates it afterwards. + if let serviceID = service.id, let cached = ModelsCache.shared.models(for: serviceID) { + fetchedModels = cached + } else { + fetchedModels = [] } + syncModelSelection() - if temperature > 0.2 { - return "Focused" + guard let config = AppConstants.defaultApiConfigurations[service.type ?? ""], + config.modelsFetching else { + isLoadingModels = false + return } - return "Deterministic" + let requestedServiceID = service.objectID + isLoadingModels = true + Task { + let models = await APIServiceManager.fetchRemoteModels( + context: viewContext, + serviceID: requestedServiceID + ) + await MainActor.run { + // Ignore responses that arrive after the user switched services. + guard selectedService?.objectID == requestedServiceID else { return } + isLoadingModels = false + if let models, !models.isEmpty, models != fetchedModels { + fetchedModels = models + syncModelSelection() + } + } + } } - private func savePersona() { + @discardableResult + private func savePersona() -> Bool { let personaToSave = persona ?? PersonaEntity(context: viewContext) personaToSave.name = name personaToSave.color = color.toHex() personaToSave.temperature = round(temperature * 10) / 10 personaToSave.systemMessage = systemMessage + personaToSave.apiService = selectedService + personaToSave.model = model + personaToSave.contextSize = Int16(contextSize) + personaToSave.useStreamResponse = useStreamResponse + personaToSave.generateChatNames = generateChatNames + personaToSave.imageGenerationEnabled = imageGenerationEnabled if persona == nil { personaToSave.addedDate = Date() personaToSave.id = UUID() @@ -348,6 +600,17 @@ struct PersonaDetailView: View { personaToSave.order = 0 print("Error fetching personas for order: \(error)") } + + let defaultRequest = NSFetchRequest(entityName: "PersonaEntity") + defaultRequest.predicate = NSPredicate(format: "isDefault == YES AND apiService != nil") + if (try? viewContext.count(for: defaultRequest)) == 0 { + let allDefaultsRequest = NSFetchRequest(entityName: "PersonaEntity") + allDefaultsRequest.predicate = NSPredicate(format: "isDefault == YES") + for existingDefault in (try? viewContext.fetch(allDefaultsRequest)) ?? [] { + existingDefault.isDefault = false + } + personaToSave.isDefault = true + } } else { personaToSave.editedDate = Date() @@ -366,24 +629,36 @@ struct PersonaDetailView: View { personaToSave.objectWillChange.send() try viewContext.save() presentationMode.wrappedValue.dismiss() + return true } catch { - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + persistenceErrorMessage = error.localizedDescription + showingPersistenceError = true + return false } } - private func deletePersona() { - if let personaToDelete = persona { - viewContext.delete(personaToDelete) - do { - try viewContext.save() - presentationMode.wrappedValue.dismiss() - } - catch { - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } + @discardableResult + private func deletePersona() -> Bool { + guard let personaToDelete = persona else { return false } + if personaToDelete.isDefault { + let request = NSFetchRequest(entityName: "PersonaEntity") + request.predicate = NSPredicate(format: "SELF != %@ AND apiService != nil", personaToDelete) + request.sortDescriptors = [NSSortDescriptor(keyPath: \PersonaEntity.order, ascending: true)] + request.fetchLimit = 1 + (try? viewContext.fetch(request).first)?.isDefault = true + } + viewContext.delete(personaToDelete) + do { + try viewContext.save() + presentationMode.wrappedValue.dismiss() + return true + } + catch { + viewContext.rollback() + persistenceErrorMessage = error.localizedDescription + showingPersistenceError = true + return false } } } diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift index de47a19..e41b854 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift @@ -4,6 +4,7 @@ // // Created by Renat Notfullin on 13.09.2024. // +import CoreData import SwiftUI struct APIServiceDetailView: View { @@ -11,13 +12,8 @@ struct APIServiceDetailView: View { @Environment(\.presentationMode) var presentationMode @State private var lampColor: Color = .gray @State private var showingDeleteConfirmation: Bool = false + @State private var isAdvancedExpanded = false @FocusState private var isFocused: Bool - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \PersonaEntity.addedDate, ascending: true)], - animation: .default - ) - private var personas: FetchedResults - init(viewContext: NSManagedObjectContext, apiService: APIServiceEntity?, preferredType: String? = nil) { let viewModel = APIServiceDetailViewModel( viewContext: viewContext, @@ -28,8 +24,6 @@ struct APIServiceDetailView: View { } private let types = AppConstants.apiTypes - @State private var previousModel = "" - @State private var loadingIconIndex = 0 var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -61,7 +55,7 @@ struct APIServiceDetailView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .onChange(of: viewModel.type) { newValue in + .onChange(of: viewModel.type) { _, newValue in viewModel.onChangeApiType(newValue) } } @@ -76,7 +70,7 @@ struct APIServiceDetailView: View { .textFieldStyle(RoundedBorderTextFieldStyle()) Button(action: { - viewModel.url = viewModel.defaultApiConfiguration!.url + viewModel.url = viewModel.defaultApiConfiguration?.url ?? AppConstants.apiUrlOpenAIResponses }) { Text("Default") } @@ -91,87 +85,28 @@ struct APIServiceDetailView: View { .textFieldStyle(RoundedBorderTextFieldStyle()) .focused($isFocused) .blur(radius: !viewModel.apiKey.isEmpty && !isFocused ? 3 : 0.0, opaque: false) - .onChange(of: viewModel.apiKey) { newValue in + .onChange(of: viewModel.apiKey) { _, newValue in viewModel.onChangeApiKey(newValue) } } - HStack { - Spacer() - Link( - "How to get API Key", - destination: URL( - string: viewModel.defaultApiConfiguration!.apiKeyRef - )! - ) - .font(.subheadline) - .foregroundColor(.blue) - } - } - - HStack { - Text("LLM Model:") - .frame(width: 94, alignment: .leading) - - Picker("", selection: $viewModel.selectedModel) { - ForEach(viewModel.availableModels.sorted(), id: \.self) { modelName in - Text(modelName).tag(modelName) - } - Text("Enter custom model").tag("custom") - } - .onChange(of: viewModel.selectedModel) { newValue in - if newValue == "custom" { - viewModel.isCustomModel = true - } - else { - viewModel.isCustomModel = false - viewModel.model = newValue + if let apiKeyRefURL = URL(string: viewModel.defaultApiConfiguration?.apiKeyRef ?? "") { + HStack { + Spacer() + Link("How to get API Key", destination: apiKeyRefURL) + .font(.subheadline) + .foregroundColor(.blue) } } - .disabled(viewModel.isLoadingModels) - - if AppConstants.defaultApiConfigurations[viewModel.type]?.modelsFetching ?? false { - ButtonWithStatusIndicator( - title: "Update", - action: { viewModel.onUpdateModelsList() }, - isLoading: viewModel.isLoadingModels, - hasError: viewModel.modelFetchError != nil, - errorMessage: - "Can't get models from server (or I don't know how), but don't worry - using default list", - successMessage: "Click to refresh models list", - isSuccess: !viewModel.isLoadingModels && viewModel.modelFetchError == nil - && viewModel.availableModels.count > 0 - ) - } - } - .padding(.top, 8) - - if viewModel.isCustomModel { - VStack { - TextField("Enter custom model name", text: $viewModel.model) - .textFieldStyle(RoundedBorderTextFieldStyle()) - } } HStack { - Spacer() - Link( - "Models reference", - destination: URL(string: viewModel.defaultApiConfiguration!.apiModelRef)! - ) - .font(.subheadline) - .foregroundColor(.blue) - } - .padding(.bottom) - - HStack { - ButtonTestApiTokenAndModel( + ButtonTestAPIConnection( lampColor: $lampColor, - gptToken: viewModel.apiKey, - gptModel: viewModel.model, + apiKey: viewModel.apiKey, apiUrl: viewModel.url, apiType: viewModel.type, - imageGenerationSupported: viewModel.imageGenerationSupported + customHeaders: viewModel.normalizedCustomHeaders ) } } @@ -179,69 +114,6 @@ struct APIServiceDetailView: View { } VStack { - HStack { - // TODO: implement unlimited context size (is it really needed though?) - // Toggle(isOn: $viewModel.contextSizeUnlimited) { - // Text("Unlimited context size") - // } - // .disabled(true) - // Spacer() - } - if !viewModel.contextSizeUnlimited { - HStack { - Slider( - value: $viewModel.contextSize, - in: 5...100, - step: 5 - ) { - Text("Context size") - } minimumValueLabel: { - Text("5") - } maximumValueLabel: { - Text("100") - } - .disabled(viewModel.contextSizeUnlimited) - - Text(String(format: ("%.0f messages"), viewModel.contextSize)) - .frame(width: 90) - } - } - }.padding(.top, 16) - - VStack { - Toggle(isOn: $viewModel.generateChatNames) { - HStack { - Text("Automatically generate chat names (using selected model)") - Button(action: { - }) { - Image(systemName: "questionmark.circle") - .foregroundColor(.blue) - } - .buttonStyle(PlainButtonStyle()) - .help( - "Chat name will be generated based on chat messages. Selected model will be used to generate chat name" - ) - - Spacer() - } - } - Toggle(isOn: $viewModel.useStreamResponse) { - HStack { - Text("Use stream responses") - Button(action: { - }) { - Image(systemName: "questionmark.circle") - .foregroundColor(.blue) - } - .buttonStyle(PlainButtonStyle()) - .help( - "If on, the ChatGPT response will be streamed to the client. This will allow you to see the response in real-time. If off, the response will be sent to the client only after the model has finished processing." - ) - - Spacer() - } - } - if viewModel.supportsImageUploads { Toggle(isOn: $viewModel.imageUploadsAllowed) { HStack { @@ -279,44 +151,45 @@ struct APIServiceDetailView: View { } } } + } + .padding(.vertical, 8) - if viewModel.supportsImageGeneration { - Toggle(isOn: $viewModel.imageGenerationSupported) { + DisclosureGroup(isExpanded: $isAdvancedExpanded) { + VStack(alignment: .leading, spacing: 8) { + ForEach($viewModel.customHeaders) { $header in HStack { - Text("Image generation supported") - Button(action: { - }) { - Image(systemName: "questionmark.circle") - .foregroundColor(.blue) + TextField("Header name", text: $header.name) + TextField("Value", text: $header.value) + Button("Remove header", systemImage: "minus.circle", role: .destructive) { + viewModel.removeCustomHeader(id: header.id) } - .buttonStyle(PlainButtonStyle()) - .help( - "Enable when the selected model should expose image generation features in chat." - ) - - Spacer() + .labelStyle(.iconOnly) } } - } - } - .padding(.vertical, 8) - HStack { - Text("Default AI Assistant:") - .frame(width: 160, alignment: .leading) + Button("Add Header", systemImage: "plus") { + viewModel.addCustomHeader() + } + .buttonStyle(.link) + + Text("Custom headers are stored securely in Keychain. Required provider headers take precedence.") + .font(.caption) + .foregroundStyle(.secondary) - Picker("", selection: $viewModel.defaultAiPersona) { - ForEach(personas) { persona in - Text(persona.name ?? "Untitled").tag(persona) + if viewModel.hasInvalidCustomHeaders { + Text("Enter a unique name and value for each header. Names cannot contain spaces or colons.") + .font(.caption) + .foregroundStyle(.red) } } - } - - if AppConstants.openAiReasoningModels.contains(viewModel.model) { - Text( - "💁‍♂️ OpenAI API doesn't support system message and temperature other than 1 for o1 models. macai will send system message as a user message internally, while temperature will be always set to 1.0" - ) - .fixedSize(horizontal: false, vertical: true) + .padding(.top, 8) + } label: { + Button("Advanced") { + withAnimation { + isAdvancedExpanded.toggle() + } + } + .buttonStyle(.plain) } HStack { @@ -344,6 +217,7 @@ struct APIServiceDetailView: View { Text("Save") } .keyboardShortcut(.defaultAction) + .disabled(viewModel.hasInvalidCustomHeaders) } .padding(.top, 16) } diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift b/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift index b8eb981..0682a4f 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift @@ -5,36 +5,34 @@ // Created by Renat Notfullin on 18.09.2024. // -import Combine +import CoreData import SwiftUI +struct EditableHTTPHeader: Identifiable, Equatable { + let id: UUID + var name: String + var value: String + + init(id: UUID = UUID(), name: String = "", value: String = "") { + self.id = id + self.name = name + self.value = value + } +} + @MainActor class APIServiceDetailViewModel: ObservableObject { private let viewContext: NSManagedObjectContext var apiService: APIServiceEntity? - private var cancellables = Set() - private var hasProcessedInitialModelSelection = false @Published var name: String = AppConstants.defaultApiConfigurations[AppConstants.defaultApiType]?.name ?? "" @Published var type: String = AppConstants.defaultApiType @Published var url: String = "" - @Published var model: String = "" - @Published var contextSize: Float = 20 - @Published var contextSizeUnlimited: Bool = false - @Published var useStreamResponse: Bool = true - @Published var generateChatNames: Bool = true @Published var imageUploadsAllowed: Bool = false @Published var pdfUploadsAllowed: Bool = false - @Published var imageGenerationSupported: Bool = false - @Published var defaultAiPersona: PersonaEntity? @Published var apiKey: String = "" - @Published var isCustomModel: Bool = false - @Published var selectedModel: String = - (AppConstants.defaultApiConfigurations[AppConstants.defaultApiType]?.defaultModel ?? "") + @Published var customHeaders: [EditableHTTPHeader] = [] @Published var defaultApiConfiguration = AppConstants.defaultApiConfigurations[AppConstants.defaultApiType] - @Published var fetchedModels: [AIModel] = [] - @Published var isLoadingModels: Bool = false - @Published var modelFetchError: String? = nil init(viewContext: NSManagedObjectContext, apiService: APIServiceEntity?, preferredType: String? = nil) { self.viewContext = viewContext @@ -46,33 +44,25 @@ class APIServiceDetailViewModel: ObservableObject { { type = preferredType defaultApiConfiguration = configuration - selectedModel = configuration.defaultModel - model = configuration.defaultModel name = configuration.name } setupInitialValues() - setupBindings() - fetchModelsForService() } private func setupInitialValues() { if let service = apiService { - name = service.name ?? defaultApiConfiguration!.name + name = service.name ?? defaultApiConfiguration?.name ?? "" type = service.type ?? AppConstants.defaultApiType url = service.url?.absoluteString ?? "" - model = service.model ?? "" - contextSize = Float(service.contextSize) - useStreamResponse = service.useStreamResponse - generateChatNames = service.generateChatNames imageUploadsAllowed = service.imageUploadsAllowed pdfUploadsAllowed = service.pdfUploadsAllowed - imageGenerationSupported = service.imageGenerationSupported - defaultAiPersona = service.defaultPersona defaultApiConfiguration = AppConstants.defaultApiConfigurations[type] - selectedModel = model if let serviceID = service.id { + customHeaders = APIServiceHeaderStore.load(for: serviceID) + .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } + .map { EditableHTTPHeader(name: $0.key, value: $0.value) } let serviceIDString = serviceID.uuidString do { apiKey = try TokenManager.getToken(for: serviceIDString) ?? "" @@ -87,98 +77,47 @@ class APIServiceDetailViewModel: ObservableObject { url = config.url imageUploadsAllowed = config.imageUploadsSupported pdfUploadsAllowed = config.pdfUploadsSupported - let selected = selectedModel.isEmpty ? config.defaultModel : selectedModel - imageGenerationSupported = Self.supportedState( - for: selected, - using: config - ) name = config.name - model = selected - selectedModel = selected defaultApiConfiguration = config } else { url = AppConstants.apiUrlOpenAIResponses imageUploadsAllowed = false pdfUploadsAllowed = false - imageGenerationSupported = false } } } - private func setupBindings() { - $selectedModel - .sink { [weak self] newValue in - guard let self else { return } - self.isCustomModel = (newValue == "custom") - if !self.isCustomModel { - self.model = newValue - } - - guard let config = self.defaultApiConfiguration else { return } - - if self.hasProcessedInitialModelSelection == false { - self.hasProcessedInitialModelSelection = true - return - } - - if Self.supportedState(for: newValue, using: config) { - self.imageGenerationSupported = true - } - } - .store(in: &cancellables) + var normalizedCustomHeaders: [String: String] { + customHeaders.reduce(into: [:]) { result, header in + let name = header.name.trimmingCharacters(in: .whitespacesAndNewlines) + let value = header.value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !value.isEmpty else { return } + result[name] = value + } } - private func fetchModelsForService() { - isLoadingModels = true - modelFetchError = nil - - let config = APIServiceConfig( - name: type, - apiUrl: URL(string: url)!, - apiKey: apiKey, - model: "" - ) - - let apiService = APIServiceFactory.createAPIService( - config: config, - imageGenerationSupported: imageGenerationSupported - ) - - Task { - do { - let models = try await apiService.fetchModels() - self.fetchedModels = models - self.isLoadingModels = false - self.updateModelSelection() - } - catch { - self.modelFetchError = error.localizedDescription - self.isLoadingModels = false - self.fetchedModels = [] - self.updateModelSelection() - } - } + var hasInvalidCustomHeaders: Bool { + let completed = customHeaders.filter { + !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !$0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + guard completed.allSatisfy({ + !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !$0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !$0.name.contains(":") + && !$0.name.contains(where: { $0.isWhitespace }) + }) else { return true } + let names = completed.map { $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + return Set(names).count != names.count } - - private func updateModelSelection() { - let modelExists = self.availableModels.contains(self.selectedModel) - - if !modelExists && !self.selectedModel.isEmpty { - self.isCustomModel = true - self.selectedModel = "custom" - } else if modelExists { - self.isCustomModel = false - } + + func addCustomHeader() { + customHeaders.append(EditableHTTPHeader()) } - var availableModels: [String] { - if fetchedModels.isEmpty == false { - return fetchedModels.map { $0.id } - } - else { - return defaultApiConfiguration?.models ?? [] - } + func removeCustomHeader(id: UUID) { + customHeaders.removeAll { $0.id == id } } func saveAPIService() { @@ -186,18 +125,16 @@ class APIServiceDetailViewModel: ObservableObject { serviceToSave.name = name serviceToSave.type = type serviceToSave.url = URL(string: url) - serviceToSave.model = model - serviceToSave.contextSize = Int16(contextSize) - serviceToSave.useStreamResponse = useStreamResponse - serviceToSave.generateChatNames = generateChatNames serviceToSave.imageUploadsAllowed = imageUploadsAllowed serviceToSave.pdfUploadsAllowed = pdfUploadsAllowed - serviceToSave.imageGenerationSupported = imageGenerationSupported - serviceToSave.defaultPersona = defaultAiPersona if serviceToSave.tokenIdentifier == nil || serviceToSave.tokenIdentifier?.isEmpty == true { serviceToSave.tokenIdentifier = UUID().uuidString } + if let config = defaultApiConfiguration { + serviceToSave.imageGenerationSupported = config.imageGenerationSupported + } + if apiService == nil { serviceToSave.addedDate = Date() serviceToSave.id = UUID() @@ -214,6 +151,7 @@ class APIServiceDetailViewModel: ObservableObject { let serviceIDString = serviceID.uuidString do { try TokenManager.setToken(apiKey, for: serviceIDString) + try APIServiceHeaderStore.save(normalizedCustomHeaders, for: serviceID) } catch { print("Failed to set token: \(error.localizedDescription)") @@ -239,9 +177,21 @@ class APIServiceDetailViewModel: ObservableObject { func deleteAPIService() { guard let serviceToDelete = apiService else { return } + let defaultRequest = NSFetchRequest(entityName: "PersonaEntity") + defaultRequest.predicate = NSPredicate(format: "isDefault == YES AND apiService == %@", serviceToDelete) + if let removedDefault = try? viewContext.fetch(defaultRequest).first { + removedDefault.isDefault = false + let replacementRequest = NSFetchRequest(entityName: "PersonaEntity") + replacementRequest.predicate = NSPredicate(format: "apiService != nil AND apiService != %@", serviceToDelete) + replacementRequest.sortDescriptors = [NSSortDescriptor(keyPath: \PersonaEntity.order, ascending: true)] + replacementRequest.fetchLimit = 1 + (try? viewContext.fetch(replacementRequest).first)?.isDefault = true + } + if let serviceID = serviceToDelete.id { let serviceIDString = serviceID.uuidString try? TokenManager.deleteToken(for: serviceIDString) + APIServiceHeaderStore.delete(for: serviceID) } viewContext.delete(serviceToDelete) @@ -254,32 +204,16 @@ class APIServiceDetailViewModel: ObservableObject { } func onChangeApiType(_ type: String) { - self.name = self.name == self.defaultApiConfiguration!.name ? "" : self.name + self.name = self.name == self.defaultApiConfiguration?.name ? "" : self.name self.defaultApiConfiguration = AppConstants.defaultApiConfigurations[type] - self.name = self.name == "" ? self.defaultApiConfiguration!.name : self.name - self.url = self.defaultApiConfiguration!.url - self.model = self.defaultApiConfiguration!.defaultModel - self.selectedModel = self.model - - self.imageUploadsAllowed = self.defaultApiConfiguration!.imageUploadsSupported - self.pdfUploadsAllowed = self.defaultApiConfiguration!.pdfUploadsSupported - self.imageGenerationSupported = Self.supportedState( - for: self.model, - using: self.defaultApiConfiguration! - ) - - self.hasProcessedInitialModelSelection = false - - fetchModelsForService() + self.name = self.name == "" ? (self.defaultApiConfiguration?.name ?? "") : self.name + self.url = self.defaultApiConfiguration?.url ?? AppConstants.apiUrlOpenAIResponses + self.imageUploadsAllowed = self.defaultApiConfiguration?.imageUploadsSupported ?? false + self.pdfUploadsAllowed = self.defaultApiConfiguration?.pdfUploadsSupported ?? false } func onChangeApiKey(_ token: String) { self.apiKey = token - fetchModelsForService() - } - - func onUpdateModelsList() { - fetchModelsForService() } var supportsImageUploads: Bool { @@ -289,17 +223,4 @@ class APIServiceDetailViewModel: ObservableObject { var supportsPdfUploads: Bool { return AppConstants.defaultApiConfigurations[type]?.pdfUploadsSupported ?? false } - - var supportsImageGeneration: Bool { - let configSupports = AppConstants.defaultApiConfigurations[type]?.imageGenerationSupported ?? false - return configSupports || imageGenerationSupported - } - - private static func supportedState( - for model: String, - using config: AppConstants.defaultApiConfiguration - ) -> Bool { - guard config.imageGenerationSupported else { return false } - return config.autoEnableImageGenerationModels.contains(model) - } } diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddView.swift b/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddView.swift index 4195f74..a18fba1 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddView.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddView.swift @@ -53,7 +53,7 @@ struct APIServiceTemplateAddView: View { selectedRowID = viewModel.selectedOptionID } } - .onChange(of: viewModel.selectedOptionID) { newValue in + .onChange(of: viewModel.selectedOptionID) { _, newValue in if viewModel.currentStep == .selectTemplate && !newValue.isEmpty { selectedRowID = newValue } @@ -120,7 +120,7 @@ struct APIServiceTemplateAddView: View { .onSubmit { isServiceNameEditing = false } - .onChange(of: viewModel.serviceName) { viewModel.handleNameChange($0) } + .onChange(of: viewModel.serviceName) { _, newValue in viewModel.handleNameChange(newValue) } } else { Text(viewModel.serviceName.isEmpty ? model.displayName : viewModel.serviceName) .foregroundColor(.secondary) diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddViewModel.swift b/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddViewModel.swift index 89529be..9f59e0f 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddViewModel.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddViewModel.swift @@ -75,9 +75,9 @@ final class APIServiceTemplateAddViewModel: ObservableObject { @Published private(set) var isTesting: Bool = false @Published private(set) var validationMessage: String? - init(viewContext: NSManagedObjectContext, defaultPersona: PersonaEntity? = nil) { + init(viewContext: NSManagedObjectContext, selectedAssistant: PersonaEntity? = nil) { self.viewContext = viewContext - self.selectedPersona = defaultPersona + self.selectedPersona = selectedAssistant let catalog = APIServiceTemplateProvider.loadCatalog() self.providers = catalog.providers @@ -322,14 +322,9 @@ final class APIServiceTemplateAddViewModel: ObservableObject { service.name = trimmedName service.type = provider.id service.url = apiURL - service.model = modelTemplate.id - service.contextSize = Int16(contextSize) - service.useStreamResponse = useStreamResponse - service.generateChatNames = generateChatNames service.imageUploadsAllowed = imageUploadsAllowed service.pdfUploadsAllowed = pdfUploadsAllowed - service.imageGenerationSupported = imageGenerationSupported - service.defaultPersona = selectedPersona + service.imageGenerationSupported = defaultApiConfiguration?.imageGenerationSupported ?? imageGenerationSupported if service.tokenIdentifier == nil || service.tokenIdentifier?.isEmpty == true { service.tokenIdentifier = UUID().uuidString } @@ -337,6 +332,54 @@ final class APIServiceTemplateAddViewModel: ObservableObject { service.id = UUID() } + let personaToUse: PersonaEntity + if let selectedPersona = selectedPersona { + if let existingService = selectedPersona.apiService, existingService != service { + let clone = PersonaEntity(context: viewContext) + clone.name = selectedPersona.name + clone.color = selectedPersona.color + clone.systemMessage = selectedPersona.systemMessage + clone.temperature = selectedPersona.temperature + clone.contextSize = selectedPersona.contextSize + clone.useStreamResponse = selectedPersona.useStreamResponse + clone.generateChatNames = selectedPersona.generateChatNames + clone.imageGenerationEnabled = selectedPersona.imageGenerationEnabled + clone.model = selectedPersona.model + clone.addedDate = Date() + clone.id = UUID() + let fetchRequest: NSFetchRequest = PersonaEntity.fetchRequest() + let maxOrder = (try? viewContext.fetch(fetchRequest))? + .map { $0.order } + .max() ?? 0 + clone.order = maxOrder + 1 + personaToUse = clone + } else { + personaToUse = selectedPersona + } + } else { + let newPersona = PersonaEntity(context: viewContext) + newPersona.name = "\(trimmedName) Assistant" + newPersona.color = AppConstants.defaultPersonaColor + newPersona.systemMessage = AppConstants.chatGptSystemMessage + newPersona.temperature = AppConstants.defaultTemperatureForChat + newPersona.addedDate = Date() + newPersona.id = UUID() + let fetchRequest: NSFetchRequest = PersonaEntity.fetchRequest() + let maxOrder = (try? viewContext.fetch(fetchRequest))? + .map { $0.order } + .max() ?? 0 + newPersona.order = maxOrder + 1 + personaToUse = newPersona + } + + personaToUse.apiService = service + personaToUse.model = model + personaToUse.contextSize = Int16(contextSize) + personaToUse.useStreamResponse = useStreamResponse + personaToUse.generateChatNames = generateChatNames + personaToUse.imageGenerationEnabled = imageGenerationSupported + ensureDefaultAssistant(personaToUse) + do { let identifier = service.id?.uuidString ?? UUID().uuidString service.id = UUID(uuidString: identifier) ?? service.id @@ -372,6 +415,16 @@ final class APIServiceTemplateAddViewModel: ObservableObject { } } + private func ensureDefaultAssistant(_ persona: PersonaEntity) { + let request = NSFetchRequest(entityName: "PersonaEntity") + request.predicate = NSPredicate(format: "isDefault == YES") + + let defaults = (try? viewContext.fetch(request)) ?? [] + guard defaults.contains(where: { $0.apiService != nil }) == false else { return } + + try? PersonaEntity.setDefault(persona, in: viewContext) + } + private func requiresApiKey(for provider: APIServiceProviderTemplate) -> Bool { guard let configuration = AppConstants.defaultApiConfigurations[provider.id] else { return true } return configuration.apiKeyRef.isEmpty == false diff --git a/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift b/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift index dd97308..747da89 100644 --- a/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift +++ b/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift @@ -7,72 +7,87 @@ import SwiftUI -struct ButtonTestApiTokenAndModel: View { +struct ButtonTestAPIConnection: View { @Binding var lampColor: Color - var gptToken: String = "" - var gptModel: String = AppConstants.defaultPrimaryModel + var apiKey: String = "" var apiUrl: String = AppConstants.apiUrlOpenAIResponses var apiType: String = AppConstants.defaultApiType - var imageGenerationSupported: Bool = false - @State var testOk: Bool = false - - @Environment(\.managedObjectContext) private var viewContext + var customHeaders: [String: String] = [:] + @State private var testOk = false + @State private var hasWarning = false + @State private var statusMessage: String? + @State private var errorMessage: String? var body: some View { ButtonWithStatusIndicator( - title: "Test API token & model", - action: { testAPI() }, + title: "Test connection", + action: testAPI, isLoading: lampColor == .yellow, hasError: lampColor == .red, - errorMessage: nil, - successMessage: "API connection test successful", - isSuccess: testOk + errorMessage: errorMessage, + successMessage: statusMessage ?? "API connection test successful", + isSuccess: testOk, + isWarning: hasWarning ) } private func testAPI() { + guard let url = URL(string: apiUrl) else { + lampColor = .red + showErrorAlert(error: "The API URL is invalid: \(apiUrl)") + return + } + lampColor = .yellow - self.testOk = false + testOk = false + hasWarning = false + statusMessage = nil + errorMessage = nil let config = APIServiceConfig( name: apiType, - apiUrl: URL(string: apiUrl)!, - apiKey: gptToken, - model: gptModel - ) - let apiService = APIServiceFactory.createAPIService( - config: config, - imageGenerationSupported: imageGenerationSupported + apiUrl: url, + apiKey: apiKey, + model: "", + customHeaders: customHeaders ) - let messageManager = MessageManager(apiService: apiService, viewContext: viewContext) - messageManager.testAPI(model: gptModel) { result in - DispatchQueue.main.async { - switch result { + let apiService = APIServiceFactory.createAPIService(config: config) - case .success(_): + Task { @MainActor in + do { + let result = try await apiService.testConnection() + if result.authenticationVerified { lampColor = .green - self.testOk = true - - case .failure(let error): - lampColor = .red - let errorMessage = - switch error as! APIError { - case .requestFailed(let error): error.localizedDescription - case .invalidResponse: "Response is invalid" - case .decodingFailed(let message): message - case .unauthorized: "Unauthorized" - case .rateLimited: "Rate limited" - case .serverError(let message): message - case .unknown(let message): message - case .noApiService(let message): message - case .attachmentNotReady(let message): message - } - showErrorAlert(error: errorMessage) + testOk = true + statusMessage = "Connection successful" + } else { + lampColor = .orange + hasWarning = true + statusMessage = "Endpoint reachable · token not verified" } + } catch { + lampColor = .red + errorMessage = Self.message(for: error) + showErrorAlert(error: errorMessage ?? "Unknown error") } } } + private static func message(for error: Error) -> String { + switch error as? APIError { + case .requestFailed(let nestedError): message(for: nestedError) + case .invalidResponse: "Response is invalid" + case .decodingFailed(let message): message + case .unauthorized: "Unauthorized. Check the API key." + case .rateLimited: "Rate limited" + case .serverError(let message): message + case .unknown(let message): message + case .noApiService(let message): message + case .attachmentNotReady(let message): message + case nil: error.localizedDescription + } + } + private func showErrorAlert(error: String) { let alert = NSAlert() alert.messageText = "API Connection Test Failed" diff --git a/macai/UI/Preferences/TabAPIServicesView.swift b/macai/UI/Preferences/TabAPIServicesView.swift index a781ab9..3b272ad 100644 --- a/macai/UI/Preferences/TabAPIServicesView.swift +++ b/macai/UI/Preferences/TabAPIServicesView.swift @@ -129,11 +129,9 @@ struct TabAPIServicesView: View { if let service = service { VStack(alignment: .leading, spacing: 8) { Text("Type of API: \(AppConstants.defaultApiConfigurations[service.type!]?.name ?? "Unknown")") - Text("Selected Model: \(service.model ?? "Not specified")") - Text("Context size: \(service.contextSize)") - Text("Auto chat name generation: \(service.generateChatNames ? "Yes" : "No")") - //Text("Stream responses: \(service.useStreamResponse ? "Yes" : "No")") - Text("Default AI Assistant: \(service.defaultPersona?.name ?? "None")") + Text("API URL: \(service.url?.absoluteString ?? "Not specified")") + Text("Image uploads: \(service.imageUploadsAllowed ? "Allowed" : "Disabled")") + Text("PDF uploads: \(service.pdfUploadsAllowed ? "Allowed" : "Disabled")") } } else { diff --git a/macai/UI/Preferences/TabGeneralSettingsView.swift b/macai/UI/Preferences/TabGeneralSettingsView.swift index 894e0e6..6fb4b26 100644 --- a/macai/UI/Preferences/TabGeneralSettingsView.swift +++ b/macai/UI/Preferences/TabGeneralSettingsView.swift @@ -8,7 +8,6 @@ import AppKit import Sparkle import SwiftUI -import AttributedText struct TabGeneralSettingsView: View { private let previewCode = """ @@ -293,7 +292,7 @@ struct TabGeneralSettingsView: View { HStack { Toggle("Automatically check for updates", isOn: $autoCheckForUpdates) - .onChange(of: autoCheckForUpdates) { newValue in + .onChange(of: autoCheckForUpdates) { _, newValue in updaterController.updater.automaticallyChecksForUpdates = newValue } @@ -520,7 +519,7 @@ struct SyncDebugLogView: View { } .id(entry.id) } - .onChange(of: filteredLogs.count) { _ in + .onChange(of: filteredLogs.count) { _, _ in if autoScroll, let lastEntry = filteredLogs.last { withAnimation { proxy.scrollTo(lastEntry.id, anchor: .bottom) diff --git a/macai/UI/WelcomeScreen/WelcomeScreen.swift b/macai/UI/WelcomeScreen/WelcomeScreen.swift index 6348b4b..1e7a0ee 100644 --- a/macai/UI/WelcomeScreen/WelcomeScreen.swift +++ b/macai/UI/WelcomeScreen/WelcomeScreen.swift @@ -32,15 +32,8 @@ struct WelcomeScreen: View { Text("Welcome to macai!").font(.title) if !apiServiceIsPresent { Text("To get started, please add at least one API Service in the settings") - if #available(macOS 14.0, *) { - SettingsLink { - Text("Open Settings") - } - } - else { - Button("Open Settings") { - openPreferencesView() - } + SettingsLink { + Text("Open Settings") } } else { diff --git a/macai/Utilities/APIHandlers/APIProtocol.swift b/macai/Utilities/APIHandlers/APIProtocol.swift index 8b90d80..e60152d 100644 --- a/macai/Utilities/APIHandlers/APIProtocol.swift +++ b/macai/Utilities/APIHandlers/APIProtocol.swift @@ -34,6 +34,9 @@ protocol APIService { func fetchModels() async throws -> [AIModel] + /// Checks the API service without sending a chat message or requiring an assistant model. + func testConnection() async throws -> APIConnectionTestResult + func cancelCurrentRequest() } @@ -42,6 +45,7 @@ protocol APIServiceConfiguration { var apiUrl: URL { get set } var apiKey: String { get set } var model: String { get set } + var customHeaders: [String: String] { get set } } struct AIModel: Codable, Identifiable { @@ -52,9 +56,27 @@ struct AIModel: Codable, Identifiable { } } +struct APIConnectionTestResult { + let discoveredModelCount: Int? + let authenticationVerified: Bool + + static func authenticated(discoveredModelCount: Int? = nil) -> APIConnectionTestResult { + APIConnectionTestResult( + discoveredModelCount: discoveredModelCount, + authenticationVerified: true + ) + } + + static let reachable = APIConnectionTestResult( + discoveredModelCount: nil, + authenticationVerified: false + ) +} + extension APIService { - func fetchModels() async throws -> [AIModel] { - return [] + func testConnection() async throws -> APIConnectionTestResult { + let models = try await fetchModels() + return .authenticated(discoveredModelCount: models.count) } func cancelCurrentRequest() {} diff --git a/macai/Utilities/APIHandlers/APIServiceConfig.swift b/macai/Utilities/APIHandlers/APIServiceConfig.swift index 91bbf7d..f37b96b 100644 --- a/macai/Utilities/APIHandlers/APIServiceConfig.swift +++ b/macai/Utilities/APIHandlers/APIServiceConfig.swift @@ -12,4 +12,31 @@ struct APIServiceConfig: APIServiceConfiguration, Codable { var apiUrl: URL var apiKey: String var model: String + var customHeaders: [String: String] = [:] +} + +enum APIServiceHeaderStore { + private static let identifier = "custom_headers" + + static func load(for serviceID: UUID) -> [String: String] { + guard let json = try? TokenManager.getToken(for: serviceID.uuidString, identifier: identifier), + let data = json.data(using: .utf8), + let headers = try? JSONDecoder().decode([String: String].self, from: data) + else { return [:] } + return headers + } + + static func save(_ headers: [String: String], for serviceID: UUID) throws { + if headers.isEmpty { + try TokenManager.deleteToken(for: serviceID.uuidString, identifier: identifier) + return + } + let data = try JSONEncoder().encode(headers) + guard let json = String(data: data, encoding: .utf8) else { return } + try TokenManager.setToken(json, for: serviceID.uuidString, identifier: identifier) + } + + static func delete(for serviceID: UUID) { + try? TokenManager.deleteToken(for: serviceID.uuidString, identifier: identifier) + } } diff --git a/macai/Utilities/APIHandlers/APIServiceFactory.swift b/macai/Utilities/APIHandlers/APIServiceFactory.swift index a49a803..3d30bcc 100644 --- a/macai/Utilities/APIHandlers/APIServiceFactory.swift +++ b/macai/Utilities/APIHandlers/APIServiceFactory.swift @@ -8,12 +8,13 @@ import Foundation class APIServiceFactory { - static let session: URLSession = { + private static func makeSession(customHeaders: [String: String]) -> URLSession { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = AppConstants.requestTimeout configuration.timeoutIntervalForResource = AppConstants.requestTimeout + configuration.httpAdditionalHeaders = customHeaders return URLSession(configuration: configuration) - }() + } static func createAPIService( config: APIServiceConfiguration, @@ -21,6 +22,7 @@ class APIServiceFactory { ) -> APIService { let configName = AppConstants.defaultApiConfigurations[config.name.lowercased()]?.inherits ?? config.name.lowercased() + let session = makeSession(customHeaders: config.customHeaders) switch configName { case "openai-responses", "openai": diff --git a/macai/Utilities/APIHandlers/ChatGPTHandler.swift b/macai/Utilities/APIHandlers/ChatGPTHandler.swift index d61c0cb..dea59c4 100644 --- a/macai/Utilities/APIHandlers/ChatGPTHandler.swift +++ b/macai/Utilities/APIHandlers/ChatGPTHandler.swift @@ -287,9 +287,9 @@ class ChatGPTHandler: OpenAIHandlerBase, APIService { let contentPart = delta["content"] as? String { - let finished = false + var finished = false if let finishReason = firstChoice["finish_reason"] as? String, finishReason == "stop" { - _ = true + finished = true } return (finished, nil, contentPart, defaultRole) } diff --git a/macai/Utilities/APIHandlers/ClaudeHandler.swift b/macai/Utilities/APIHandlers/ClaudeHandler.swift index 837fb98..d9f1d01 100644 --- a/macai/Utilities/APIHandlers/ClaudeHandler.swift +++ b/macai/Utilities/APIHandlers/ClaudeHandler.swift @@ -184,7 +184,8 @@ class ClaudeHandler: APIService { } let maxTokens = - model == "claude-3-5-sonnet-latest" ? 8192 : AppConstants.defaultApiConfigurations["claude"]!.maxTokens! + model == "claude-3-5-sonnet-latest" + ? 8192 : (AppConstants.defaultApiConfigurations["claude"]?.maxTokens ?? 8192) let jsonDict: [String: Any] = [ "model": model, @@ -288,7 +289,7 @@ class ClaudeHandler: APIService { if let eventType = json["type"] as? String { switch eventType { - case "contenxt_block_start": + case "content_block_start": if let contentBlock = json["content_block"] as? [String: Any], let text = contentBlock["text"] as? String { diff --git a/macai/Utilities/APIHandlers/OpenAIResponsesHandler.swift b/macai/Utilities/APIHandlers/OpenAIResponsesHandler.swift index 6c2805e..118da01 100644 --- a/macai/Utilities/APIHandlers/OpenAIResponsesHandler.swift +++ b/macai/Utilities/APIHandlers/OpenAIResponsesHandler.swift @@ -238,10 +238,8 @@ class OpenAIResponsesHandler: OpenAIHandlerBase, APIService { let textContent = AttachmentParser.stripAttachments(from: content) var contentArray: [[String: Any]] = [] - let textType = role == "assistant" ? "output_text" : "input_text" - if !textContent.isEmpty { - contentArray.append(["type": textType, "text": textContent]) + contentArray.append(["type": "input_text", "text": textContent]) } if role != "assistant" { diff --git a/macai/Utilities/APIHandlers/PerplexityHandler.swift b/macai/Utilities/APIHandlers/PerplexityHandler.swift index ad6c386..0deb1b1 100644 --- a/macai/Utilities/APIHandlers/PerplexityHandler.swift +++ b/macai/Utilities/APIHandlers/PerplexityHandler.swift @@ -25,6 +25,48 @@ class PerplexityHandler: APIService { self.session = session } + func fetchModels() async throws -> [AIModel] { + throw APIError.unknown("Model discovery is not supported by Perplexity") + } + + func testConnection() async throws -> APIConnectionTestResult { + var request = URLRequest(url: baseURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + do { + let (_, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + switch httpResponse.statusCode { + case 200...299: + return .authenticated() + case 401, 403: + throw APIError.unauthorized + case 429: + throw APIError.rateLimited + case 404: + throw APIError.serverError("Endpoint not found: \(baseURL.absoluteString)") + case 500...599: + throw APIError.serverError("Server Error: HTTP \(httpResponse.statusCode)") + case 400, 405, 422: + // Perplexity has no model-independent authentication endpoint. A response from + // the configured endpoint confirms reachability, but not the validity of the key. + return .reachable + default: + throw APIError.serverError("Unexpected HTTP status: \(httpResponse.statusCode)") + } + } + catch let error as APIError { + throw error + } + catch { + throw APIError.requestFailed(error) + } + } + func sendMessage( _ requestMessages: [[String: String]], temperature: Float, diff --git a/macai/Utilities/APIServiceManager.swift b/macai/Utilities/APIServiceManager.swift index 86d3707..62b5071 100644 --- a/macai/Utilities/APIServiceManager.swift +++ b/macai/Utilities/APIServiceManager.swift @@ -15,16 +15,22 @@ class APIServiceManager { self.viewContext = viewContext } - func createAPIService(name: String, type: String, url: URL, model: String, contextSize: Int16, useStreamResponse: Bool, generateChatNames: Bool) -> APIServiceEntity { + func createAPIService( + name: String, + type: String, + url: URL, + imageUploadsAllowed: Bool, + pdfUploadsAllowed: Bool, + imageGenerationSupported: Bool + ) -> APIServiceEntity { let apiService = APIServiceEntity(context: viewContext) apiService.id = UUID() apiService.name = name apiService.type = type apiService.url = url - apiService.model = model - apiService.contextSize = contextSize - apiService.useStreamResponse = useStreamResponse - apiService.generateChatNames = generateChatNames + apiService.imageUploadsAllowed = imageUploadsAllowed + apiService.pdfUploadsAllowed = pdfUploadsAllowed + apiService.imageGenerationSupported = imageGenerationSupported apiService.tokenIdentifier = UUID().uuidString do { @@ -36,14 +42,21 @@ class APIServiceManager { return apiService } - func updateAPIService(_ apiService: APIServiceEntity, name: String, type: String, url: URL, model: String, contextSize: Int16, useStreamResponse: Bool, generateChatNames: Bool) { + func updateAPIService( + _ apiService: APIServiceEntity, + name: String, + type: String, + url: URL, + imageUploadsAllowed: Bool, + pdfUploadsAllowed: Bool, + imageGenerationSupported: Bool + ) { apiService.name = name apiService.type = type apiService.url = url - apiService.model = model - apiService.contextSize = contextSize - apiService.useStreamResponse = useStreamResponse - apiService.generateChatNames = generateChatNames + apiService.imageUploadsAllowed = imageUploadsAllowed + apiService.pdfUploadsAllowed = pdfUploadsAllowed + apiService.imageGenerationSupported = imageGenerationSupported do { try viewContext.save() @@ -86,3 +99,72 @@ class APIServiceManager { } } } + +extension APIServiceManager { + static func fetchRemoteModels( + context: NSManagedObjectContext, + serviceID: NSManagedObjectID + ) async -> [String]? { + var snapshot: ( + type: String, + url: URL, + serviceID: UUID, + defaultModel: String, + imageGenerationSupported: Bool + )? + + context.performAndWait { + guard let service = try? context.existingObject(with: serviceID) as? APIServiceEntity else { + return + } + guard let type = service.type, + let url = service.url, + let id = service.id else { + return + } + guard let config = AppConstants.defaultApiConfigurations[type], + config.modelsFetching else { + return + } + snapshot = ( + type: type, + url: url, + serviceID: id, + defaultModel: config.defaultModel, + imageGenerationSupported: service.imageGenerationSupported + ) + } + + guard let snapshot else { + return nil + } + + let apiKey = (try? TokenManager.getToken(for: snapshot.serviceID.uuidString)) ?? "" + if apiKey.isEmpty { + return nil + } + + let apiConfig = APIServiceConfig( + name: snapshot.type, + apiUrl: snapshot.url, + apiKey: apiKey, + model: snapshot.defaultModel, + customHeaders: APIServiceHeaderStore.load(for: snapshot.serviceID) + ) + + let handler = APIServiceFactory.createAPIService( + config: apiConfig, + imageGenerationSupported: snapshot.imageGenerationSupported + ) + + do { + let models = try await handler.fetchModels() + let ids = models.map { $0.id }.filter { $0.isEmpty == false }.sorted() + ModelsCache.shared.store(ids, for: snapshot.serviceID) + return ids + } catch { + print("Failed to fetch models for \(snapshot.type): \(error)") + return nil + } + } +} diff --git a/macai/Utilities/ChatExporter.swift b/macai/Utilities/ChatExporter.swift new file mode 100644 index 0000000..f5d064b --- /dev/null +++ b/macai/Utilities/ChatExporter.swift @@ -0,0 +1,343 @@ +// +// ChatExporter.swift +// macai +// +// Created by Renat Notfullin on 07.07.2026. +// + +import AppKit +import CoreData +import Foundation +import UniformTypeIdentifiers + +enum ChatExportFormat: String, CaseIterable { + case markdown + case plainText + + var displayName: String { + switch self { + case .markdown: return "Markdown" + case .plainText: return "Plain Text" + } + } + + var fileExtension: String { + switch self { + case .markdown: return "md" + case .plainText: return "txt" + } + } + + var contentType: UTType { + switch self { + case .markdown: return UTType(filenameExtension: "md") ?? .plainText + case .plainText: return .plainText + } + } +} + +/// Renders a chat transcript to Markdown or plain text for export. +struct ChatExporter { + struct ExportMessage { + let isUser: Bool + let timestamp: Date + let body: String + } + + struct ExportImage { + let data: Data + let mimeType: String + } + + static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + // MARK: - Rendering + + static func render( + chatName: String, + systemMessage: String?, + messages: [ExportMessage], + format: ChatExportFormat, + fileNameResolver: (UUID) -> String? = { _ in nil }, + imageResolver: (UUID) -> ExportImage? = { _ in nil } + ) -> String { + let title = chatName.trimmingCharacters(in: .whitespacesAndNewlines) + var sections: [String] = [] + + switch format { + case .markdown: + var headerParts: [String] = [] + if !title.isEmpty { + headerParts.append("# \(title)") + } + if let systemMessage = normalized(systemMessage), !systemMessage.isEmpty { + let quoted = systemMessage + .components(separatedBy: "\n") + .map { "> \($0)" } + .joined(separator: "\n") + headerParts.append("**System prompt:**\n\n\(quoted)") + } + if !headerParts.isEmpty { + sections.append(headerParts.joined(separator: "\n\n")) + } + for message in messages { + guard + let body = exportableBody( + message.body, + format: format, + fileNameResolver: fileNameResolver, + imageResolver: imageResolver + ) + else { continue } + let role = message.isUser ? "User" : "Assistant" + let timestamp = timestampFormatter.string(from: message.timestamp) + sections.append("#### \(role) — \(timestamp)\n\n\(body)") + } + return sections.joined(separator: "\n\n---\n\n") + "\n" + + case .plainText: + if !title.isEmpty { + sections.append(title + "\n" + String(repeating: "=", count: min(title.count, 60))) + } + if let systemMessage = normalized(systemMessage), !systemMessage.isEmpty { + sections.append("System prompt:\n\(systemMessage)") + } + for message in messages { + guard let body = exportableBody(message.body, format: format, fileNameResolver: fileNameResolver) + else { continue } + let role = message.isUser ? "User" : "Assistant" + let timestamp = timestampFormatter.string(from: message.timestamp) + sections.append("\(role) — \(timestamp):\n\(body)") + } + return sections.joined(separator: "\n\n" + String(repeating: "-", count: 40) + "\n\n") + "\n" + } + } + + /// Normalizes a raw message body for export: strips reasoning blocks and + /// replaces attachment placeholders with readable markers. + /// Returns nil if nothing remains to export. + static func exportableBody( + _ body: String, + format: ChatExportFormat, + fileNameResolver: (UUID) -> String? = { _ in nil }, + imageResolver: (UUID) -> ExportImage? = { _ in nil } + ) -> String? { + var result = removingThinkingBlocks(from: body) + + result = replacingPlaceholders(in: result, tag: "image-uuid") { uuid in + if format == .markdown, let uuid, let image = imageResolver(uuid) { + let base64 = image.data.base64EncodedString() + return "![Attached image](data:\(image.mimeType);base64,\(base64))" + } + return format == .markdown ? "*[Attached image]*" : "[Attached image]" + } + result = replacingPlaceholders(in: result, tag: "file-uuid") { uuid in + let name = uuid.flatMap(fileNameResolver) + let label = name.map { "Attached file: \($0)" } ?? "Attached file" + return format == .markdown ? "*[\(label)]*" : "[\(label)]" + } + + let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Removes `…` reasoning blocks. An unterminated block is + /// removed to the end of the string (streaming may be cut off mid-thought). + static func removingThinkingBlocks(from text: String) -> String { + guard text.contains("") else { return text } + var result = text + if let regex = try? NSRegularExpression(pattern: ".*?", options: [.dotMatchesLineSeparators]) { + result = regex.stringByReplacingMatches( + in: result, + options: [], + range: NSRange(result.startIndex..., in: result), + withTemplate: "" + ) + } + if let openRange = result.range(of: "") { + result = String(result[.. String + ) -> String { + guard text.contains("<\(tag)>") else { return text } + guard let regex = try? NSRegularExpression(pattern: "<\(tag)>(.*?)", options: []) else { return text } + + var result = "" + var currentIndex = text.startIndex + let nsText = text as NSString + let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length)) + for match in matches { + guard let matchRange = Range(match.range, in: text) else { continue } + result += text[currentIndex.. 1 + ? nsText.substring(with: match.range(at: 1)) + : "" + result += replacement(UUID(uuidString: uuidString)) + currentIndex = matchRange.upperBound + } + result += text[currentIndex...] + return result + } + + private static func normalized(_ text: String?) -> String? { + text?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Core Data integration + + static func export(chat: ChatEntity, format: ChatExportFormat) -> String { + let messages = chat.messagesArray.map { + ExportMessage(isUser: $0.own, timestamp: $0.timestamp, body: $0.body) + } + let context = chat.managedObjectContext + return render( + chatName: chat.name, + systemMessage: chat.effectiveSystemMessage, + messages: messages, + format: format, + fileNameResolver: { uuid in + guard let context else { return nil } + return documentFilename(for: uuid, in: context) + }, + imageResolver: { uuid in + guard let context else { return nil } + return imageData(for: uuid, in: context) + } + ) + } + + static func export(chats: [ChatEntity], format: ChatExportFormat) -> String { + combine( + transcripts: chats.map { export(chat: $0, format: format) }, + format: format + ) + } + + static func combine(transcripts: [String], format: ChatExportFormat) -> String { + let separator = format == .markdown + ? "\n\n---\n\n" + : "\n\n\(String(repeating: "=", count: 60))\n\n" + let content = transcripts.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: separator) + return content.isEmpty ? "" : content.appending("\n") + } + + static func suggestedFileName(for chat: ChatEntity, format: ChatExportFormat) -> String { + var name = chat.name.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty { + name = "Chat" + } + let invalidCharacters = CharacterSet(charactersIn: "/\\:?%*|\"<>") + name = name.components(separatedBy: invalidCharacters).joined(separator: " ") + .trimmingCharacters(in: .whitespaces) + if name.isEmpty { + name = "Chat" + } + return "\(name).\(format.fileExtension)" + } + + static func suggestedFileName(chatCount: Int, format: ChatExportFormat) -> String { + "\(chatCount) Chats.\(format.fileExtension)" + } + + private static func documentFilename(for uuid: UUID, in context: NSManagedObjectContext) -> String? { + var filename: String? + context.performAndWait { + let fetchRequest: NSFetchRequest = DocumentEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", uuid as CVarArg) + fetchRequest.fetchLimit = 1 + filename = (try? context.fetch(fetchRequest))?.first?.filename + } + return filename + } + + private static func imageData(for uuid: UUID, in context: NSManagedObjectContext) -> ExportImage? { + var exportImage: ExportImage? + context.performAndWait { + let fetchRequest: NSFetchRequest = ImageEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", uuid as CVarArg) + fetchRequest.fetchLimit = 1 + guard let entity = (try? context.fetch(fetchRequest))?.first, let data = entity.image else { return } + exportImage = ExportImage(data: data, mimeType: mimeType(forImageFormat: entity.imageFormat)) + } + return exportImage + } + + private static func mimeType(forImageFormat format: String?) -> String { + switch format?.lowercased() { + case "png": return "image/png" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "heic": return "image/heic" + case "tiff", "tif": return "image/tiff" + case "bmp": return "image/bmp" + default: return "image/jpeg" + } + } + + // MARK: - Save panel + + static func exportWithSavePanel(chat: ChatEntity, format: ChatExportFormat) { + exportWithSavePanel(chats: [chat], format: format) + } + + static func exportWithSavePanel(chats: [ChatEntity], format: ChatExportFormat) { + guard !chats.isEmpty else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [format.contentType] + panel.canCreateDirectories = true + panel.isExtensionHidden = false + panel.nameFieldStringValue = chats.count == 1 + ? suggestedFileName(for: chats[0], format: format) + : suggestedFileName(chatCount: chats.count, format: format) + panel.title = chats.count == 1 ? "Export Chat" : "Export \(chats.count) Chats" + + guard panel.runModal() == .OK, let url = panel.url else { return } + + // Render and write off the main thread: embedding images base64-encodes + // potentially many megabytes of data. + let chatIDs = chats.map(\.objectID) + guard chatIDs.allSatisfy({ !$0.isTemporaryID }), + let coordinator = chats[0].managedObjectContext?.persistentStoreCoordinator else { + writeExport(export(chats: chats, format: format), to: url) + return + } + + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.persistentStoreCoordinator = coordinator + context.perform { + let backgroundChats = chatIDs.compactMap { + try? context.existingObject(with: $0) as? ChatEntity + } + guard !backgroundChats.isEmpty else { return } + let content = export(chats: backgroundChats, format: format) + writeExport(content, to: url) + } + } + + private static func writeExport(_ content: String, to url: URL) { + do { + try content.write(to: url, atomically: true, encoding: .utf8) + } catch { + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Export Failed" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.presentAsSheetOrModal { _ in } + } + } + } +} diff --git a/macai/Utilities/DatabasePatcher.swift b/macai/Utilities/DatabasePatcher.swift index 8783da4..b6e3d7a 100644 --- a/macai/Utilities/DatabasePatcher.swift +++ b/macai/Utilities/DatabasePatcher.swift @@ -24,7 +24,8 @@ class DatabasePatcher { AppConstants.openAiResponsesPdfUploadsPatchCompletedKey, AppConstants.geminiPdfUploadsPatchCompletedKey, AppConstants.openRouterUploadsPatchCompletedKey, - AppConstants.defaultApiServiceMigrationCompletedKey + AppConstants.defaultApiServiceMigrationCompletedKey, + AppConstants.assistantSettingsMigrationCompletedKey ] for key in keys { @@ -62,6 +63,14 @@ class DatabasePatcher { static func applyPatches(context: NSManagedObjectContext, persistence: PersistenceController) { addDefaultPersonasIfNeeded(context: context) patchMissingIDs(context: context, persistence: persistence) + migrateDefaultAPIServiceSelectionIfNeeded(context: context, persistence: persistence) + + if persistence.getMetadata(forKey: AppConstants.assistantSettingsMigrationCompletedKey) as? Bool != true { + if migrateAssistantSettingsToPersonas(context: context) { + persistence.setMetadata(value: true, forKey: AppConstants.assistantSettingsMigrationCompletedKey) + markAssistantSettingsMigrationNoticePendingIfNeeded(context: context) + } + } if persistence.getMetadata(forKey: AppConstants.personaOrderingPatchCompletedKey) as? Bool != true { if patchPersonaOrdering(context: context) { @@ -105,16 +114,27 @@ class DatabasePatcher { } } - migrateDefaultAPIServiceSelectionIfNeeded(context: context, persistence: persistence) - backfillMessageSequencesIfNeeded(context: context, persistence: persistence) patchGeminiLegacyEndpoint(context: context, persistence: persistence) patchChatGPTLegacyEndpoint(context: context, persistence: persistence) } + private static func markAssistantSettingsMigrationNoticePendingIfNeeded( + context: NSManagedObjectContext, + defaults: UserDefaults = .standard + ) { + let request = NSFetchRequest(entityName: "APIServiceEntity") + request.fetchLimit = 1 + + context.performAndWait { + guard (try? context.count(for: request)) ?? 0 > 0 else { return } + defaults.set(true, forKey: AppConstants.assistantSettingsMigrationNoticePendingKey) + } + } + static func initializeDatabaseIfNeeded(context: NSManagedObjectContext, persistence: PersistenceController) { let initializationKey = "DB_INITIALIZED" - let latestVersion = 3 + let latestVersion = 4 if persistence.getMetadata(forKey: initializationKey) as? Bool == true { return } @@ -152,6 +172,7 @@ class DatabasePatcher { persistence.setMetadata(value: true, forKey: AppConstants.geminiPdfUploadsPatchCompletedKey) persistence.setMetadata(value: true, forKey: AppConstants.openRouterUploadsPatchCompletedKey) persistence.setMetadata(value: true, forKey: AppConstants.defaultApiServiceMigrationCompletedKey) + persistence.setMetadata(value: true, forKey: AppConstants.assistantSettingsMigrationCompletedKey) // Set latest DB version to skip all current and future patches that are already "included" in fresh DB persistence.setMetadata(value: latestVersion, forKey: "DB_VERSION") @@ -216,6 +237,11 @@ class DatabasePatcher { newPersona.systemMessage = persona.message newPersona.addedDate = Date() newPersona.temperature = persona.temperature + newPersona.contextSize = Int16(AppConstants.chatGptContextSize) + newPersona.generateChatNames = true + newPersona.useStreamResponse = true + newPersona.imageGenerationEnabled = false + newPersona.model = AppConstants.defaultPrimaryModel newPersona.id = UUID() newPersona.order = Int16(index) } @@ -231,6 +257,288 @@ class DatabasePatcher { } } + /// Returns true when the migration completed (including as a no-op), false when it + /// failed and should be retried on the next launch. + static func migrateAssistantSettingsToPersonas(context: NSManagedObjectContext) -> Bool { + var didChange = false + var failed = false + + context.performAndWait { + let personaRequest = NSFetchRequest(entityName: "PersonaEntity") + let serviceRequest = NSFetchRequest(entityName: "APIServiceEntity") + let chatRequest = NSFetchRequest(entityName: "ChatEntity") + + guard let personas = try? context.fetch(personaRequest), + let services = try? context.fetch(serviceRequest), + let chats = try? context.fetch(chatRequest) + else { + failed = true + return + } + + // Capture the v3 relationships before mutating anything. A persona could + // be shared by several services, so later decisions must not depend on + // whichever service happens to be processed first. + let originalChatServices = Dictionary( + uniqueKeysWithValues: chats.map { ($0.objectID, $0.apiService?.objectID) } + ) + let originalChatPersonas = Dictionary( + uniqueKeysWithValues: chats.map { ($0.objectID, $0.persona?.objectID) } + ) + let originalDefaultPersonas = Dictionary( + uniqueKeysWithValues: services.map { ($0.objectID, $0.defaultPersona?.objectID) } + ) + + var personaServiceCandidates: [NSManagedObjectID: Set] = [:] + for chat in chats { + guard let persona = chat.persona, + let service = chat.apiService else { continue } + personaServiceCandidates[persona.objectID, default: []].insert(service.objectID) + } + + for service in services { + if let defaultPersona = service.defaultPersona { + personaServiceCandidates[defaultPersona.objectID, default: []].insert(service.objectID) + } + } + + func applyDefaults(to persona: PersonaEntity, service: APIServiceEntity?) { + if persona.model == nil || persona.model?.isEmpty == true { + if let service { + persona.model = service.model ?? AppConstants.defaultModel(for: service.type) + } else { + persona.model = AppConstants.defaultPrimaryModel + } + didChange = true + } + + if persona.contextSize == 0 { + if let service, service.contextSize > 0 { + persona.contextSize = service.contextSize + } else { + persona.contextSize = Int16(AppConstants.chatGptContextSize) + } + didChange = true + } + + if let service { + persona.generateChatNames = service.generateChatNames + persona.useStreamResponse = service.useStreamResponse + persona.imageGenerationEnabled = service.imageGenerationSupported + didChange = true + } + } + + func clonePersona(_ persona: PersonaEntity, order: Int16) -> PersonaEntity { + let clone = PersonaEntity(context: context) + clone.name = persona.name + clone.color = persona.color + clone.systemMessage = persona.systemMessage + clone.temperature = persona.temperature + clone.contextSize = persona.contextSize + clone.generateChatNames = persona.generateChatNames + clone.useStreamResponse = persona.useStreamResponse + clone.imageGenerationEnabled = persona.imageGenerationEnabled + clone.model = persona.model + clone.addedDate = Date() + clone.id = UUID() + clone.order = order + return clone + } + + struct PersonaServiceKey: Hashable { + let personaID: NSManagedObjectID + let serviceID: NSManagedObjectID + } + + let servicesByID = Dictionary(uniqueKeysWithValues: services.map { ($0.objectID, $0) }) + var migratedPersonaByPair: [PersonaServiceKey: PersonaEntity] = [:] + var maxOrder = personas.map { $0.order }.max() ?? 0 + + for persona in personas { + let candidateIDs = (personaServiceCandidates[persona.objectID] ?? []) + .sorted { $0.uriRepresentation().absoluteString < $1.uriRepresentation().absoluteString } + + if candidateIDs.isEmpty { + applyDefaults(to: persona, service: persona.apiService) + continue + } + + for (index, serviceID) in candidateIDs.enumerated() { + guard let service = servicesByID[serviceID] else { continue } + let migratedPersona: PersonaEntity + if index == 0 { + migratedPersona = persona + } else { + maxOrder += 1 + migratedPersona = clonePersona(persona, order: maxOrder) + } + migratedPersona.apiService = service + applyDefaults(to: migratedPersona, service: service) + migratedPersonaByPair[ + PersonaServiceKey(personaID: persona.objectID, serviceID: serviceID) + ] = migratedPersona + didChange = true + } + } + + var preferredPersonaByService: [NSManagedObjectID: PersonaEntity] = [:] + for service in services { + let boundPersona = (Array(migratedPersonaByPair.values) + personas) + .filter { $0.apiService?.objectID == service.objectID } + .min(by: { $0.order < $1.order }) + let defaultPersona: PersonaEntity + if let originalPersonaID = originalDefaultPersonas[service.objectID] ?? nil, + let mapped = migratedPersonaByPair[ + PersonaServiceKey(personaID: originalPersonaID, serviceID: service.objectID) + ] { + defaultPersona = mapped + } else if let existing = service.defaultPersona, + existing.apiService == nil || existing.apiService == service { + existing.apiService = service + applyDefaults(to: existing, service: service) + defaultPersona = existing + didChange = true + } else if let bound = boundPersona { + defaultPersona = bound + } else { + maxOrder += 1 + let newPersona = PersonaEntity(context: context) + newPersona.name = (service.name?.isEmpty == false) + ? "\(service.name ?? "Service") Assistant" + : "Assistant" + newPersona.color = AppConstants.defaultPersonaColor + newPersona.systemMessage = AppConstants.chatGptSystemMessage + newPersona.temperature = AppConstants.defaultTemperatureForChat + newPersona.contextSize = service.contextSize > 0 + ? service.contextSize + : Int16(AppConstants.chatGptContextSize) + newPersona.generateChatNames = service.generateChatNames + newPersona.useStreamResponse = service.useStreamResponse + newPersona.imageGenerationEnabled = service.imageGenerationSupported + newPersona.model = service.model ?? AppConstants.defaultModel(for: service.type) + newPersona.addedDate = Date() + newPersona.id = UUID() + newPersona.order = maxOrder + newPersona.apiService = service + defaultPersona = newPersona + didChange = true + } + + preferredPersonaByService[service.objectID] = defaultPersona + // Clear the old service-owned selection after promoting one assistant + // to the global default. The relationship remains in Model 4 solely so + // lightweight migration can carry this value across without data loss. + if service.defaultPersona != nil { + service.defaultPersona = nil + didChange = true + } + } + + let validExistingDefaults = personas.filter { $0.isDefault && $0.apiService != nil } + func personaComesBefore(_ lhs: PersonaEntity, _ rhs: PersonaEntity) -> Bool { + if lhs.order != rhs.order { return lhs.order < rhs.order } + return lhs.objectID.uriRepresentation().absoluteString + < rhs.objectID.uriRepresentation().absoluteString + } + let orderedServices = services.sorted { + $0.objectID.uriRepresentation().absoluteString + < $1.objectID.uriRepresentation().absoluteString + } + let selectedDefault: PersonaEntity? = { + if let existing = validExistingDefaults.min(by: personaComesBefore) { + return existing + } + if let defaultService = orderedServices.first(where: { $0.isDefault }), + let preferred = preferredPersonaByService[defaultService.objectID] { + return preferred + } + return preferredPersonaByService.values.min(by: personaComesBefore) + }() + + let allMigratedPersonas = personas + + Array(migratedPersonaByPair.values) + + Array(preferredPersonaByService.values) + for persona in allMigratedPersonas { + let shouldBeDefault = persona === selectedDefault + if persona.isDefault != shouldBeDefault { + persona.isDefault = shouldBeDefault + didChange = true + } + } + + for chat in chats { + let originalServiceID = originalChatServices[chat.objectID] ?? nil + let originalPersonaID = originalChatPersonas[chat.objectID] ?? nil + + if let originalServiceID, + let originalPersonaID, + let mappedPersona = migratedPersonaByPair[ + PersonaServiceKey(personaID: originalPersonaID, serviceID: originalServiceID) + ] { + chat.persona = mappedPersona + chat.apiService = servicesByID[originalServiceID] + didChange = true + } + + if chat.persona == nil, let service = chat.apiService { + if let defaultPersona = preferredPersonaByService[service.objectID] { + chat.persona = defaultPersona + if chat.systemMessage.isEmpty { + chat.systemMessage = defaultPersona.systemMessage ?? AppConstants.chatGptSystemMessage + } + didChange = true + } + } + + if let persona = chat.persona, let personaService = persona.apiService { + // Preserve an explicit legacy mismatch. Otherwise the chat follows + // the migrated assistant and future assistant edits stay live. + let apiServiceOverridden = chat.apiService?.objectID != personaService.objectID + let modelOverridden = !chat.gptModel.isEmpty + && chat.gptModel != (persona.model ?? "") + let systemMessageOverridden = !chat.systemMessage.isEmpty + && chat.systemMessage != (persona.systemMessage ?? "") + if chat.apiServiceOverridden != apiServiceOverridden + || chat.modelOverridden != modelOverridden + || chat.systemMessageOverridden != systemMessageOverridden { + chat.apiServiceOverridden = apiServiceOverridden + chat.modelOverridden = modelOverridden + chat.systemMessageOverridden = systemMessageOverridden + didChange = true + } + + if chat.apiService == nil { + chat.apiService = personaService + chat.apiServiceOverridden = false + didChange = true + } + if (chat.gptModel.isEmpty) && (persona.model?.isEmpty == false) { + chat.gptModel = persona.model ?? chat.gptModel + didChange = true + } + } else if chat.gptModel.isEmpty, let service = chat.apiService { + chat.gptModel = service.model ?? AppConstants.defaultModel(for: service.type) + chat.apiServiceOverridden = true + chat.modelOverridden = true + didChange = true + } + } + + if didChange { + do { + try context.save() + } catch { + print("Error migrating assistant settings: \(error)") + context.rollback() + failed = true + } + } + } + + return !failed + } + @discardableResult static func patchPersonaOrdering(context: NSManagedObjectContext) -> Bool { let fetchRequest = NSFetchRequest(entityName: "PersonaEntity") @@ -747,14 +1055,14 @@ class DatabasePatcher { chatContext = AppConstants.chatGptContextSize } + let config = AppConstants.defaultApiConfigurations[type] let apiService = apiServiceManager.createAPIService( name: name, type: type, url: URL(string: actualApiUrl)!, - model: gptModel, - contextSize: chatContext.toInt16() ?? 15, - useStreamResponse: useStream, - generateChatNames: useChatGptForNames + imageUploadsAllowed: config?.imageUploadsSupported ?? false, + pdfUploadsAllowed: config?.pdfUploadsSupported ?? false, + imageGenerationSupported: config?.imageGenerationSupported ?? false ) if let token = defaults.string(forKey: "gptToken"), @@ -768,12 +1076,22 @@ class DatabasePatcher { // Set Default Assistant as the default for default API service let personaFetchRequest = NSFetchRequest(entityName: "PersonaEntity") personaFetchRequest.predicate = NSPredicate(format: "name == %@", "Default Assistant") + var migratedDefaultPersona: PersonaEntity? do { let defaultPersonas = try context.fetch(personaFetchRequest) if let defaultPersona = defaultPersonas.first { + migratedDefaultPersona = defaultPersona print("Found default assistant: \(defaultPersona.name ?? "")") - apiService.defaultPersona = defaultPersona + if defaultPersona.apiService == nil { + defaultPersona.apiService = apiService + } + defaultPersona.isDefault = true + defaultPersona.model = gptModel + defaultPersona.contextSize = chatContext.toInt16() ?? 15 + defaultPersona.useStreamResponse = useStream + defaultPersona.generateChatNames = useChatGptForNames + defaultPersona.imageGenerationEnabled = apiService.imageGenerationSupported try context.save() print("Successfully set default assistant for API service") } @@ -793,7 +1111,12 @@ class DatabasePatcher { for chat in existingChats { chat.apiService = apiService - chat.gptModel = apiService.model ?? AppConstants.defaultModel(for: apiService.type) + if let defaultPersona = migratedDefaultPersona { + chat.persona = defaultPersona + chat.gptModel = defaultPersona.model ?? AppConstants.defaultModel(for: apiService.type) + } else { + chat.gptModel = AppConstants.defaultModel(for: apiService.type) + } } try context.save() diff --git a/macai/Utilities/Extensions.swift b/macai/Utilities/Extensions.swift index 2b28531..b845223 100644 --- a/macai/Utilities/Extensions.swift +++ b/macai/Utilities/Extensions.swift @@ -45,6 +45,18 @@ extension String { } } +extension Float { + /// Human-readable description of a sampling temperature, matching the + /// wording used across assistant settings UI. + var temperatureDescriptor: String { + if self > 0.8 { return "Creative" } + if self > 0.6 { return "Explorative" } + if self > 0.4 { return "Balanced" } + if self > 0.2 { return "Focused" } + return "Deterministic" + } +} + extension NSManagedObjectContext { func saveWithRetry(attempts: Int) { if concurrencyType == .mainQueueConcurrencyType, Thread.isMainThread { @@ -74,6 +86,19 @@ extension NSManagedObjectContext { } } +extension NSAlert { + /// Presents the alert as a sheet on the key (or main) window, falling back + /// to an app-modal dialog when no window is available. Avoids crashing on + /// `NSApp.keyWindow!` when an action is triggered without a key window. + func presentAsSheetOrModal(completionHandler: @escaping (NSApplication.ModalResponse) -> Void) { + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + beginSheetModal(for: window, completionHandler: completionHandler) + } else { + completionHandler(runModal()) + } + } +} + extension View { func border(width: CGFloat, edges: [Edge], color: Color) -> some View { overlay(EdgeBorder(width: width, edges: edges).foregroundColor(color)) diff --git a/macai/Utilities/MessageManager.swift b/macai/Utilities/MessageManager.swift index 7a9cf81..77704ab 100644 --- a/macai/Utilities/MessageManager.swift +++ b/macai/Utilities/MessageManager.swift @@ -34,7 +34,7 @@ class MessageManager: ObservableObject { ) { let requestMessages = prepareRequestMessages(userMessage: message, chat: chat, contextSize: contextSize) chat.waitingForResponse = true - let temperature = (chat.persona?.temperature ?? AppConstants.defaultTemperatureForChat).roundedToOneDecimal() + let temperature = chat.effectiveTemperature.roundedToOneDecimal() let handleResult: (Result) -> Void = { [weak self] result in guard let self = self else { return } @@ -44,7 +44,7 @@ class MessageManager: ObservableObject { chat.waitingForResponse = false let partsEnvelope = encodePartsEnvelope( from: (self.apiService as? GeminiHandler)?.consumeLastResponseParts(), - serviceType: chat.apiService?.type + serviceType: chat.effectiveApiService?.type ) self.addMessageToChat(chat: chat, message: messageBody, partsEnvelope: partsEnvelope) self.addNewMessageToRequestMessages( @@ -91,7 +91,7 @@ class MessageManager: ObservableObject { ) { cancelRequested = false let requestMessages = prepareRequestMessages(userMessage: message, chat: chat, contextSize: contextSize) - let temperature = (chat.persona?.temperature ?? AppConstants.defaultTemperatureForChat).roundedToOneDecimal() + let temperature = chat.effectiveTemperature.roundedToOneDecimal() streamTask?.cancel() streamTask = Task { [weak self] in @@ -160,7 +160,7 @@ class MessageManager: ObservableObject { let geminiParts = encodePartsEnvelope( from: (self.apiService as? GeminiHandler)?.consumeLastResponseParts(), - serviceType: chat.apiService?.type + serviceType: chat.effectiveApiService?.type ) if deferImageResponse { @@ -221,11 +221,20 @@ class MessageManager: ObservableObject { apiService.cancelCurrentRequest() } - func generateChatNameIfNeeded(chat: ChatEntity, force: Bool = false) { - guard force || chat.name == "", !chat.messagesArray.isEmpty else { + func generateChatNameIfNeeded( + chat: ChatEntity, + force: Bool = false, + completion: ((Result) -> Void)? = nil + ) { + // `force` (explicit "Regenerate Name") must work even when the chat + // is already named; the name-emptiness check only gates auto-generation. + guard force || (chat.shouldGenerateChatNames && chat.name == ""), + !chat.messagesArray.isEmpty + else { #if DEBUG print("Chat name not needed, skipping generation") #endif + completion?(.success(nil)) return } @@ -234,26 +243,34 @@ class MessageManager: ObservableObject { chat: chat, contextSize: 3 ) - let personaTemperature = (chat.persona?.temperature ?? AppConstants.defaultTemperatureForChat) - .roundedToOneDecimal() + let personaTemperature = chat.effectiveTemperature.roundedToOneDecimal() apiService.sendMessage(requestMessages, temperature: personaTemperature) { [weak self] result in guard let self = self else { return } - switch result { - case .success(let messageBody): - let chatName = self.sanitizeChatName(messageBody) - chat.name = chatName - self.viewContext.saveWithRetry(attempts: 3) - case .failure(let error): - print("Error generating chat name: \(error)") + self.viewContext.perform { + guard !chat.isDeleted else { + completion?(.success(nil)) + return + } + + switch result { + case .success(let messageBody): + let chatName = MessageManager.sanitizeChatName(messageBody) + chat.name = chatName + self.viewContext.saveWithRetry(attempts: 3) + completion?(.success(chatName)) + case .failure(let error): + print("Error generating chat name: \(error)") + completion?(.failure(error)) + } } } } - private func sanitizeChatName(_ rawName: String) -> String { - if let range = rawName.range(of: "**(.+?)**", options: .regularExpression) { + static func sanitizeChatName(_ rawName: String) -> String { + if let range = rawName.range(of: "\\*\\*(.+?)\\*\\*", options: .regularExpression) { return String(rawName[range]).trimmingCharacters(in: CharacterSet(charactersIn: "*")) } @@ -335,7 +352,6 @@ class MessageManager: ObservableObject { } private func updateLastMessage(chat: ChatEntity, lastMessage: MessageEntity, accumulatedResponse: String) { - print("Streaming chunk received: \(accumulatedResponse.suffix(20))") lastMessage.body = accumulatedResponse lastMessage.timestamp = Date() lastMessage.waitingForResponse = false @@ -348,17 +364,18 @@ class MessageManager: ObservableObject { { var messages: [[String: String]] = [] - if !AppConstants.openAiReasoningModels.contains(chat.gptModel) { + let activeModel = chat.effectiveModel + if !AppConstants.openAiReasoningModels.contains(activeModel) { messages.append([ "role": "system", - "content": chat.systemMessage, + "content": chat.effectiveSystemMessage, ]) } else { // Models like o1-mini and o1-preview don't support "system" role. However, we can pass the system message with "user" role instead. messages.append([ "role": "user", - "content": "Take this message as the system message: \(chat.systemMessage)", + "content": "Take this message as the system message: \(chat.effectiveSystemMessage)", ]) } diff --git a/macai/Utilities/ModelsCache.swift b/macai/Utilities/ModelsCache.swift new file mode 100644 index 0000000..5f3e97d --- /dev/null +++ b/macai/Utilities/ModelsCache.swift @@ -0,0 +1,56 @@ +// +// ModelsCache.swift +// macai +// +// Created by Renat Notfullin on 07.07.2026. +// + +import Foundation + +/// Caches fetched model lists per API service so model pickers can show +/// content immediately while a background refresh runs. +final class ModelsCache { + static let shared = ModelsCache() + + private let defaults: UserDefaults + private var memoryCache: [UUID: [String]] = [:] + private let queue = DispatchQueue(label: "com.macai.modelsCache") + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func models(for serviceID: UUID) -> [String]? { + queue.sync { + if let cached = memoryCache[serviceID] { + return cached + } + guard let stored = defaults.stringArray(forKey: Self.storageKey(for: serviceID)), + !stored.isEmpty + else { + return nil + } + memoryCache[serviceID] = stored + return stored + } + } + + func store(_ models: [String], for serviceID: UUID) { + guard !models.isEmpty else { return } + queue.sync { + memoryCache[serviceID] = models + defaults.set(models, forKey: Self.storageKey(for: serviceID)) + } + } + + func removeModels(for serviceID: UUID) { + queue.sync { + memoryCache.removeValue(forKey: serviceID) + defaults.removeObject(forKey: Self.storageKey(for: serviceID)) + } + } + + private static func storageKey(for serviceID: UUID) -> String { + "cachedModels-\(serviceID.uuidString)" + } +} diff --git a/macai/Vendor/AttributedText/AttributedText.swift b/macai/Vendor/AttributedText/AttributedText.swift new file mode 100644 index 0000000..f30932c --- /dev/null +++ b/macai/Vendor/AttributedText/AttributedText.swift @@ -0,0 +1,60 @@ +// +// Vendored from https://github.com/gonzalezreal/AttributedText (MIT License, +// Copyright (c) 2020 Guille Gonzalez). Vendored to fix window sizing: the +// NSTextView's intrinsic text width must be advisory, not required. +// + +import SwiftUI + +/// A view that displays styled attributed text. +public struct AttributedText: View { + private var textSizeViewModel = TextSizeViewModel() + @State private var textSize: CGSize? + + private let attributedText: NSAttributedString + private let onOpenLink: ((URL) -> Void)? + + /// Creates an attributed text view. + /// - Parameters: + /// - attributedText: An attributed string to display. + /// - onOpenLink: The action to perform when the user opens a link in the text. When not specified, + /// the view opens the links using the `OpenURLAction` from the environment. + public init(_ attributedText: NSAttributedString, onOpenLink: ((URL) -> Void)? = nil) { + self.attributedText = attributedText + self.onOpenLink = onOpenLink + } + + /// Creates an attributed text view. + /// - Parameters: + /// - attributedText: A closure that creates the attributed string to display. + /// - onOpenLink: The action to perform when the user opens a link in the text. When not specified, + /// the view opens the links using the `OpenURLAction` from the environment. + public init(attributedText: () -> NSAttributedString, onOpenLink: ((URL) -> Void)? = nil) { + self.init(attributedText(), onOpenLink: onOpenLink) + } + + public var body: some View { + GeometryReader { geometry in + AttributedTextImpl( + attributedText: attributedText, + maxLayoutWidth: geometry.maxWidth, + textSizeViewModel: textSizeViewModel, + onOpenLink: onOpenLink + ) + } + .frame( + idealWidth: textSize?.width, + idealHeight: textSize?.height + ) + .fixedSize(horizontal: false, vertical: true) + .onReceive(textSizeViewModel.$textSize) { size in + textSize = size + } + } +} + +extension GeometryProxy { + fileprivate var maxWidth: CGFloat { + size.width - safeAreaInsets.leading - safeAreaInsets.trailing + } +} diff --git a/macai/Vendor/AttributedText/AttributedTextImpl+macOS.swift b/macai/Vendor/AttributedText/AttributedTextImpl+macOS.swift new file mode 100644 index 0000000..50325bf --- /dev/null +++ b/macai/Vendor/AttributedText/AttributedTextImpl+macOS.swift @@ -0,0 +1,99 @@ +// +// Vendored from https://github.com/gonzalezreal/AttributedText (MIT License, +// Copyright (c) 2020 Guille Gonzalez). Vendored to fix window sizing: the +// NSTextView's intrinsic text width must be advisory, not required. +// + +#if os(macOS) + import SwiftUI + + extension AttributedTextImpl: NSViewRepresentable { + func makeNSView(context: Context) -> TextView { + let nsView = TextView(frame: .zero) + + nsView.drawsBackground = false + nsView.textContainerInset = .zero + nsView.isEditable = false + nsView.isRichText = false + nsView.textContainer?.lineFragmentPadding = 0 + // we are setting the container's width manually + nsView.textContainer?.widthTracksTextView = false + nsView.delegate = context.coordinator + + // macai fix: keep the intrinsic text width advisory. With the default + // (required-ish) compression resistance, AppKit propagates the widest + // rendered line through NSHostingView and the split views into the + // window's minimum size, making the window impossible to shrink. + nsView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + return nsView + } + + func updateNSView(_ nsView: TextView, context: Context) { + nsView.textStorage?.setAttributedString(attributedText) + nsView.maxLayoutWidth = maxLayoutWidth + + nsView.textContainer?.maximumNumberOfLines = context.environment.lineLimit ?? 0 + nsView.textContainer?.lineBreakMode = NSLineBreakMode( + truncationMode: context.environment.truncationMode + ) + context.coordinator.openLink = onOpenLink ?? { context.environment.openURL($0) } + textSizeViewModel.didUpdateTextView(nsView) + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + } + + extension AttributedTextImpl { + final class TextView: NSTextView { + var maxLayoutWidth: CGFloat { + get { textContainer?.containerSize.width ?? 0 } + set { + guard textContainer?.containerSize.width != newValue else { return } + textContainer?.containerSize.width = newValue + invalidateIntrinsicContentSize() + } + } + + /// Natural size of the laid-out text. Reported to SwiftUI through + /// TextSizeViewModel; deliberately kept out of `intrinsicContentSize`. + var naturalContentSize: NSSize { + guard maxLayoutWidth > 0, + let textContainer = self.textContainer, + let layoutManager = self.layoutManager + else { + return super.intrinsicContentSize + } + + layoutManager.ensureLayout(for: textContainer) + return layoutManager.usedRect(for: textContainer).size + } + + // macai fix: never advertise the text width to Auto Layout. `fittingSize` + // honors the intrinsic width even at low compression-resistance priority, + // and NSHostingView/NSSplitView propagate it into the window's minimum + // size — the window then cannot be made narrower than the widest rendered + // line. Height stays intrinsic so vertical layout keeps working. + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: naturalContentSize.height) + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var openLink: ((URL) -> Void)? + + func textView(_: NSTextView, clickedOnLink link: Any, at _: Int) -> Bool { + guard let openLink = self.openLink, + let url = (link as? URL) ?? (link as? String).flatMap(URL.init(string:)) + else { + return false + } + + openLink(url) + return true + } + } + } +#endif diff --git a/macai/Vendor/AttributedText/AttributedTextImpl.swift b/macai/Vendor/AttributedText/AttributedTextImpl.swift new file mode 100644 index 0000000..00d2e0d --- /dev/null +++ b/macai/Vendor/AttributedText/AttributedTextImpl.swift @@ -0,0 +1,14 @@ +// +// Vendored from https://github.com/gonzalezreal/AttributedText (MIT License, +// Copyright (c) 2020 Guille Gonzalez). Vendored to fix window sizing: the +// NSTextView's intrinsic text width must be advisory, not required. +// + +import SwiftUI + +struct AttributedTextImpl { + var attributedText: NSAttributedString + var maxLayoutWidth: CGFloat + var textSizeViewModel: TextSizeViewModel + var onOpenLink: ((URL) -> Void)? +} diff --git a/macai/Vendor/AttributedText/NSLineBreakMode+TruncationMode.swift b/macai/Vendor/AttributedText/NSLineBreakMode+TruncationMode.swift new file mode 100644 index 0000000..545a2b9 --- /dev/null +++ b/macai/Vendor/AttributedText/NSLineBreakMode+TruncationMode.swift @@ -0,0 +1,22 @@ +// +// Vendored from https://github.com/gonzalezreal/AttributedText (MIT License, +// Copyright (c) 2020 Guille Gonzalez). Vendored to fix window sizing: the +// NSTextView's intrinsic text width must be advisory, not required. +// + +import SwiftUI + +extension NSLineBreakMode { + init(truncationMode: Text.TruncationMode) { + switch truncationMode { + case .head: + self = .byTruncatingHead + case .tail: + self = .byTruncatingTail + case .middle: + self = .byTruncatingMiddle + @unknown default: + self = .byWordWrapping + } + } +} diff --git a/macai/Vendor/AttributedText/TextSizeViewModel.swift b/macai/Vendor/AttributedText/TextSizeViewModel.swift new file mode 100644 index 0000000..89be72f --- /dev/null +++ b/macai/Vendor/AttributedText/TextSizeViewModel.swift @@ -0,0 +1,17 @@ +// +// Vendored from https://github.com/gonzalezreal/AttributedText (MIT License, +// Copyright (c) 2020 Guille Gonzalez). Vendored to fix window sizing: the +// NSTextView's intrinsic text width must be advisory, not required. +// + +import SwiftUI + +final class TextSizeViewModel { + @Published var textSize: CGSize? + + func didUpdateTextView(_ textView: AttributedTextImpl.TextView) { + // macai fix: intrinsicContentSize no longer carries the width (see + // AttributedTextImpl+macOS.swift); use the natural text size instead. + textSize = textView.naturalContentSize + } +} diff --git a/macai/macaiApp.swift b/macai/macaiApp.swift index 0be668a..ce72863 100644 --- a/macai/macaiApp.swift +++ b/macai/macaiApp.swift @@ -12,6 +12,66 @@ import Sparkle import SwiftUI import UserNotifications +#if DEBUG +/// Command-line window-sizing diagnostics: launch the app with +/// MACAI_MEASURE_MIN_WIDTH=1 to print the effective minimum-width constraints +/// of the main window and every view subtree, then exit. Used to verify +/// inspector/window sizing fixes without manual resizing. +enum WindowMetricsDebug { + // stderr is unbuffered, so lines survive even if the process is killed. + private static func log(_ line: String) { + FileHandle.standardError.write(Data(("MEASURE " + line + "\n").utf8)) + } + + static func runIfRequested() { + guard ProcessInfo.processInfo.environment["MACAI_MEASURE_MIN_WIDTH"] == "1" else { return } + log("scheduled") + DispatchQueue.main.asyncAfter(deadline: .now() + 6) { + log("firing; windows=\(NSApp.windows.count) visible=\(NSApp.windows.filter(\.isVisible).count)") + guard let window = NSApp.windows.first(where: { $0.isVisible && $0.contentView != nil }), + let content = window.contentView else { + log("no visible window") + exit(1) + } + log("initial frame.width=\(Int(window.frame.width)) minSize.width=\(Int(window.minSize.width)) contentMinSize.width=\(Int(window.contentMinSize.width))") + log("contentView.fittingSize.width=\(Int(content.fittingSize.width))") + + if let toolbar = window.toolbar { + for item in toolbar.items { + let x = item.view.flatMap { $0.window != nil ? Int($0.convert($0.bounds, to: nil).origin.x) : nil } + log("toolbar item id=\(item.itemIdentifier.rawValue) label='\(item.label)' x=\(x.map(String.init) ?? "-")") + } + } + + // Try shrinking programmatically: does minSize track the frame, + // or does AppKit push the window back to a fixed minimum? + var target = window.frame + target.size.width = 500 + window.setFrame(target, display: true) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + log("after setFrame(500): frame.width=\(Int(window.frame.width)) minSize.width=\(Int(window.minSize.width)) contentMinSize.width=\(Int(window.contentMinSize.width))") + dumpTree(content, depth: 0) + exit(0) + } + } + } + + private static func dumpTree(_ view: NSView, depth: Int) { + guard depth <= 30 else { return } + let name = String(describing: type(of: view)) + let fitting = view.fittingSize.width + // Only wide-demanding branches are interesting. + if fitting > 100 { + log("\(String(repeating: "| ", count: depth))\(name) fitting=\(Int(fitting)) frame=\(Int(view.frame.width))") + } + for sub in view.subviews { + dumpTree(sub, depth: depth + 1) + } + } +} +#endif + // This view model class publishes when new updates can be checked by the user final class CheckForUpdatesViewModel: ObservableObject { @Published var canCheckForUpdates = false @@ -388,14 +448,47 @@ struct macaiApp: App { } + private func postExportChatNotification(format: ChatExportFormat) { + NotificationCenter.default.post( + name: AppConstants.exportChatNotification, + object: nil, + userInfo: ["format": format.rawValue] + ) + } + + /// "Export Chat" menu items, disabled while no chat is selected in the + /// focused window (ContentView publishes the flag via focusedSceneValue). + private struct ExportChatMenu: View { + @FocusedValue(\.chatIsSelected) private var chatIsSelected: Bool? + let post: (ChatExportFormat) -> Void + + var body: some View { + Menu("Export Chat") { + Button("As Markdown…") { + post(.markdown) + } + .keyboardShortcut("e", modifiers: [.command, .shift]) + + Button("As Plain Text…") { + post(.plainText) + } + } + .disabled(chatIsSelected != true) + } + } + var body: some Scene { WindowGroup { ContentView() .environment(\.managedObjectContext, persistenceController.container.viewContext) .preferredColorScheme(preferredColorScheme) - + .onAppear { + #if DEBUG + WindowMetricsDebug.runIfRequested() + #endif + } } - .onChange(of: scenePhase) { phase in + .onChange(of: scenePhase) { _, phase in if phase == .active { if UserDefaults.standard.bool(forKey: "autoCheckForUpdates") { updaterController.updater.checkForUpdatesInBackground() @@ -439,6 +532,10 @@ struct macaiApp: App { ) } .keyboardShortcut(".", modifiers: .command) + + Divider() + + ExportChatMenu(post: postExportChatNotification) } CommandGroup(replacing: .newItem) { @@ -451,6 +548,15 @@ struct macaiApp: App { } .keyboardShortcut("n", modifiers: .command) + Button("New Chat with Assistant…") { + NotificationCenter.default.post( + name: AppConstants.selectAssistantNotification, + object: nil, + userInfo: ["windowId": NSApp.keyWindow?.windowNumber ?? 0] + ) + } + .keyboardShortcut("n", modifiers: [.command, .shift]) + Button("New Window") { NSApplication.shared.sendAction(Selector(("newWindowForTab:")), to: nil, from: nil) } @@ -475,3 +581,16 @@ struct macaiApp: App { } } } + +private struct ChatIsSelectedFocusKey: FocusedValueKey { + typealias Value = Bool +} + +extension FocusedValues { + /// Whether the focused window currently has a chat selected; drives + /// enablement of chat-dependent menu commands. + var chatIsSelected: Bool? { + get { self[ChatIsSelectedFocusKey.self] } + set { self[ChatIsSelectedFocusKey.self] = newValue } + } +} diff --git a/macaiTests/Utilities/ChatEntityTests.swift b/macaiTests/Utilities/ChatEntityTests.swift new file mode 100644 index 0000000..c578893 --- /dev/null +++ b/macaiTests/Utilities/ChatEntityTests.swift @@ -0,0 +1,341 @@ +// +// ChatEntityTests.swift +// macaiTests +// +// Created by Renat Notfullin on 07.07.2026. +// + +import CoreData +import XCTest + +@testable import macai + +final class ChatEntityTests: XCTestCase { + + private var container: NSPersistentContainer! + private var context: NSManagedObjectContext! + + override func setUpWithError() throws { + try super.setUpWithError() + + ValueTransformer.setValueTransformer( + RequestMessagesTransformer(), + forName: RequestMessagesTransformer.name + ) + + guard let model = NSManagedObjectModel.mergedModel(from: [Bundle(for: ChatEntity.self)]) else { + throw XCTSkip("Core Data model not found in bundle") + } + + container = NSPersistentContainer(name: "macaiDataModel", managedObjectModel: model) + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + container.persistentStoreDescriptions = [description] + + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error + } + if let loadError { + throw loadError + } + context = container.viewContext + } + + override func tearDownWithError() throws { + context = nil + container = nil + try super.tearDownWithError() + } + + private func makeChat() -> ChatEntity { + let chat = ChatEntity(context: context) + chat.id = UUID() + chat.createdDate = Date() + chat.updatedDate = Date() + chat.systemMessage = "Test system message" + chat.gptModel = "test-model" + chat.name = "Test Chat" + chat.lastSequence = 0 + return chat + } + + private func makeMessage(body: String, own: Bool, timestamp: Date = Date()) -> MessageEntity { + let message = MessageEntity(context: context) + message.body = body + message.own = own + message.timestamp = timestamp + return message + } + + // MARK: - Sequencing + + func testAddToMessagesAssignsIncreasingSequences() { + let chat = makeChat() + + let first = makeMessage(body: "one", own: true) + let second = makeMessage(body: "two", own: false) + let third = makeMessage(body: "three", own: true) + + chat.addToMessages(first) + chat.addToMessages(second) + chat.addToMessages(third) + + XCTAssertEqual(first.sequence, 1) + XCTAssertEqual(second.sequence, 2) + XCTAssertEqual(third.sequence, 3) + XCTAssertEqual(chat.lastSequence, 3) + } + + func testNextSequenceContinuesAfterExistingMax() throws { + let chat = makeChat() + + let message = makeMessage(body: "existing", own: true) + message.sequence = 41 + message.chat = chat + try context.save() + + // Simulate a stale lastSequence (e.g. after iCloud sync merged messages) + chat.lastSequence = 0 + + XCTAssertEqual(chat.nextSequence(), 42) + } + + func testMessagesArraySortedBySequence() throws { + let chat = makeChat() + let now = Date() + + // Insert out of chronological order with explicit sequences + let late = makeMessage(body: "late", own: false, timestamp: now.addingTimeInterval(-100)) + late.sequence = 2 + late.chat = chat + let early = makeMessage(body: "early", own: true, timestamp: now) + early.sequence = 1 + early.chat = chat + try context.save() + + let bodies = chat.messagesArray.map { $0.body } + XCTAssertEqual(bodies, ["early", "late"]) + } + + func testLastMessageReturnsHighestSequence() throws { + let chat = makeChat() + + for index in 1...5 { + let message = makeMessage(body: "message \(index)", own: index % 2 == 0) + chat.addToMessages(message) + } + try context.save() + + XCTAssertEqual(chat.lastMessage?.body, "message 5") + XCTAssertEqual(chat.messagesCount, 5) + } + + func testClearMessagesRemovesAllAndResetsSequence() throws { + let chat = makeChat() + + chat.addToMessages(makeMessage(body: "one", own: true)) + chat.addToMessages(makeMessage(body: "two", own: false)) + try context.save() + + chat.clearMessages() + try context.save() + + XCTAssertEqual(chat.messagesCount, 0) + XCTAssertEqual(chat.lastSequence, 0) + XCTAssertTrue(chat.newChat) + } + + func testApplySequenceIfNeededKeepsExistingSequence() { + let chat = makeChat() + + let message = makeMessage(body: "presequenced", own: true) + message.sequence = 7 + chat.applySequenceIfNeeded(to: message) + + XCTAssertEqual(message.sequence, 7) + XCTAssertEqual(chat.lastSequence, 7) + + let next = makeMessage(body: "next", own: false) + chat.applySequenceIfNeeded(to: next) + XCTAssertEqual(next.sequence, 8) + } + + // MARK: - Per-chat settings inheritance + + private func makePersona(temperature: Float = 0.7, contextSize: Int16 = 30, model: String = "persona-model") -> PersonaEntity { + let persona = PersonaEntity(context: context) + persona.id = UUID() + persona.name = "Test Assistant" + persona.temperature = temperature + persona.contextSize = contextSize + persona.model = model + return persona + } + + private func makeService(name: String = "Service") -> APIServiceEntity { + let service = APIServiceEntity(context: context) + service.id = UUID() + service.name = name + service.type = "chatgpt" + return service + } + + func testEffectiveTemperatureInheritsFromPersonaUntilOverridden() { + let chat = makeChat() + chat.persona = makePersona(temperature: 0.7) + + // Legacy chats carry stale temperature values (old versions wrote 0/1 + // unconditionally); without the flag they must still inherit. + chat.temperature = 1.0 + XCTAssertEqual(chat.effectiveTemperature, 0.7) + + chat.temperature = 0.2 + chat.temperatureOverridden = true + XCTAssertEqual(chat.effectiveTemperature, 0.2) + } + + func testEffectiveContextSizeInheritsFromPersonaUntilOverridden() { + let chat = makeChat() + chat.persona = makePersona(contextSize: 30) + + XCTAssertEqual(chat.effectiveContextSize, 30) + + chat.contextSize = 50 + XCTAssertEqual(chat.effectiveContextSize, 50) + } + + func testEffectiveModelPrefersChatOverPersona() { + let chat = makeChat() + chat.persona = makePersona(model: "persona-model") + + chat.gptModel = "chat-model" + chat.modelOverridden = true + XCTAssertEqual(chat.effectiveModel, "chat-model") + + chat.modelOverridden = false + XCTAssertEqual(chat.effectiveModel, "persona-model") + } + + func testEffectiveApiServicePrefersChatOverPersona() { + let chat = makeChat() + let persona = makePersona() + let personaService = makeService(name: "Persona Service") + let chatService = makeService(name: "Chat Service") + persona.apiService = personaService + chat.persona = persona + + chat.apiService = chatService + chat.apiServiceOverridden = true + XCTAssertEqual(chat.effectiveApiService?.objectID, chatService.objectID) + + chat.apiServiceOverridden = false + XCTAssertEqual(chat.effectiveApiService?.objectID, personaService.objectID) + } + + func testAssistantChangesRemainLiveUntilChatOverridesThem() { + let chat = makeChat() + let persona = makePersona(model: "first-model") + let firstService = makeService(name: "First Service") + let secondService = makeService(name: "Second Service") + persona.apiService = firstService + persona.systemMessage = "First system message" + chat.persona = persona + chat.apiService = firstService + chat.gptModel = "first-model" + chat.systemMessage = "First system message" + + persona.apiService = secondService + persona.model = "second-model" + persona.systemMessage = "Second system message" + + XCTAssertEqual(chat.effectiveApiService?.objectID, secondService.objectID) + XCTAssertEqual(chat.effectiveModel, "second-model") + XCTAssertEqual(chat.effectiveSystemMessage, "Second system message") + + chat.apiServiceOverridden = true + chat.modelOverridden = true + chat.systemMessageOverridden = true + XCTAssertEqual(chat.effectiveApiService?.objectID, firstService.objectID) + XCTAssertEqual(chat.effectiveModel, "first-model") + XCTAssertEqual(chat.effectiveSystemMessage, "First system message") + } + + func testImageGenerationModeOverridesPersona() { + let chat = makeChat() + let persona = makePersona() + persona.imageGenerationEnabled = true + chat.persona = persona + + XCTAssertTrue(chat.imageGenerationEnabled) // inherit + + chat.imageGenerationMode = 2 + XCTAssertFalse(chat.imageGenerationEnabled) // explicit off + + chat.imageGenerationMode = 1 + XCTAssertTrue(chat.imageGenerationEnabled) // explicit on + } + + func testResetToAssistantDefaultsClearsOverridesAndResnapshots() { + let chat = makeChat() + let persona = makePersona(temperature: 0.7, contextSize: 30, model: "persona-model") + let personaService = makeService(name: "Persona Service") + persona.apiService = personaService + persona.systemMessage = "Persona system message" + chat.persona = persona + + chat.temperature = 0.1 + chat.temperatureOverridden = true + chat.contextSize = 90 + chat.imageGenerationMode = 2 + chat.gptModel = "chat-model" + chat.apiService = makeService(name: "Chat Service") + chat.apiServiceOverridden = true + chat.modelOverridden = true + chat.systemMessageOverridden = true + + chat.resetToAssistantDefaults() + + XCTAssertFalse(chat.temperatureOverridden) + XCTAssertEqual(chat.effectiveTemperature, 0.7) + XCTAssertEqual(chat.effectiveContextSize, 30) + XCTAssertEqual(chat.imageGenerationMode, 0) + XCTAssertEqual(chat.gptModel, "persona-model") + XCTAssertEqual(chat.apiService?.objectID, personaService.objectID) + XCTAssertEqual(chat.systemMessage, "Persona system message") + XCTAssertFalse(chat.apiServiceOverridden) + XCTAssertFalse(chat.modelOverridden) + XCTAssertFalse(chat.systemMessageOverridden) + } + + func testPreferredAssistantUsesGlobalDefaultAndItsService() throws { + let firstService = makeService(name: "First") + let defaultService = makeService(name: "Default") + let first = makePersona() + first.order = 0 + first.apiService = firstService + let selected = makePersona(model: "default-model") + selected.order = 10 + selected.apiService = defaultService + selected.isDefault = true + + let result = try PersonaEntity.preferredAssistant(in: context) + + XCTAssertEqual(result?.objectID, selected.objectID) + XCTAssertEqual(result?.apiService?.objectID, defaultService.objectID) + } + + func testPreferredAssistantForServiceStaysWithinThatService() throws { + let globalService = makeService(name: "Global") + let requestedService = makeService(name: "Requested") + let global = makePersona() + global.apiService = globalService + global.isDefault = true + let requested = makePersona(model: "requested-model") + requested.apiService = requestedService + + let result = try PersonaEntity.preferredAssistant(in: context, for: requestedService) + + XCTAssertEqual(result?.objectID, requested.objectID) + } +} diff --git a/macaiTests/Utilities/ChatExporterTests.swift b/macaiTests/Utilities/ChatExporterTests.swift new file mode 100644 index 0000000..ae053ec --- /dev/null +++ b/macaiTests/Utilities/ChatExporterTests.swift @@ -0,0 +1,258 @@ +// +// ChatExporterTests.swift +// macaiTests +// +// Created by Renat Notfullin on 07.07.2026. +// + +import XCTest + +@testable import macai + +class ChatExporterTests: XCTestCase { + + private func message(_ body: String, isUser: Bool, timestamp: Date = Date(timeIntervalSince1970: 0)) + -> ChatExporter.ExportMessage + { + ChatExporter.ExportMessage(isUser: isUser, timestamp: timestamp, body: body) + } + + // MARK: - Thinking block removal + + func testRemovingThinkingBlocksStripsClosedBlock() { + let input = "internal reasoning\nThe answer is 42." + let result = ChatExporter.removingThinkingBlocks(from: input) + XCTAssertEqual(result.trimmingCharacters(in: .whitespacesAndNewlines), "The answer is 42.") + } + + func testRemovingThinkingBlocksStripsMultilineBlock() { + let input = "line one\nline two\nline threeAnswer" + let result = ChatExporter.removingThinkingBlocks(from: input) + XCTAssertEqual(result, "Answer") + } + + func testRemovingThinkingBlocksStripsUnterminatedBlock() { + let input = "Answer first.\nstreaming was cut off here" + let result = ChatExporter.removingThinkingBlocks(from: input) + XCTAssertEqual(result.trimmingCharacters(in: .whitespacesAndNewlines), "Answer first.") + } + + func testRemovingThinkingBlocksLeavesPlainTextUntouched() { + let input = "Nothing to strip here." + XCTAssertEqual(ChatExporter.removingThinkingBlocks(from: input), input) + } + + // MARK: - Attachment placeholders + + func testExportableBodyReplacesImagePlaceholder() { + let uuid = UUID().uuidString + let input = "Look at this:\n\(uuid)\nNice, right?" + + let markdown = ChatExporter.exportableBody(input, format: .markdown) + XCTAssertEqual(markdown, "Look at this:\n*[Attached image]*\nNice, right?") + + let plain = ChatExporter.exportableBody(input, format: .plainText) + XCTAssertEqual(plain, "Look at this:\n[Attached image]\nNice, right?") + } + + func testExportableBodyReplacesFilePlaceholderWithResolvedName() { + let uuid = UUID() + let input = "\(uuid.uuidString)\nSummarize this document." + + let result = ChatExporter.exportableBody(input, format: .plainText) { resolvedUUID in + XCTAssertEqual(resolvedUUID, uuid) + return "report.pdf" + } + XCTAssertEqual(result, "[Attached file: report.pdf]\nSummarize this document.") + } + + func testExportableBodyReplacesFilePlaceholderWithoutResolvedName() { + let input = "\(UUID().uuidString) Question" + let result = ChatExporter.exportableBody(input, format: .plainText) { _ in nil } + XCTAssertEqual(result, "[Attached file] Question") + } + + func testExportableBodyEmbedsImageAsDataURIInMarkdown() { + let uuid = UUID() + let imageData = Data([0xFF, 0xD8, 0xFF, 0xE0]) + let input = "Check this out:\n\(uuid.uuidString)" + + let result = ChatExporter.exportableBody(input, format: .markdown, imageResolver: { resolvedUUID in + XCTAssertEqual(resolvedUUID, uuid) + return ChatExporter.ExportImage(data: imageData, mimeType: "image/jpeg") + }) + + let expectedURI = "data:image/jpeg;base64,\(imageData.base64EncodedString())" + XCTAssertEqual(result, "Check this out:\n![Attached image](\(expectedURI))") + } + + func testExportableBodyDoesNotEmbedImageInPlainText() { + let input = "\(UUID().uuidString) What is this?" + let result = ChatExporter.exportableBody(input, format: .plainText, imageResolver: { _ in + ChatExporter.ExportImage(data: Data([0x01]), mimeType: "image/png") + }) + XCTAssertEqual(result, "[Attached image] What is this?") + } + + func testExportableBodyFallsBackToMarkerWhenImageMissing() { + let input = "\(UUID().uuidString)" + let result = ChatExporter.exportableBody(input, format: .markdown, imageResolver: { _ in nil }) + XCTAssertEqual(result, "*[Attached image]*") + } + + func testExportableBodyHandlesMultiplePlaceholdersInOneLine() { + let input = "\(UUID().uuidString)\(UUID().uuidString)Compare" + let result = ChatExporter.exportableBody(input, format: .plainText) + XCTAssertEqual(result, "[Attached image][Attached image]Compare") + } + + func testExportableBodyReturnsNilForEmptyResult() { + XCTAssertNil(ChatExporter.exportableBody("", format: .markdown)) + XCTAssertNil(ChatExporter.exportableBody(" \n ", format: .markdown)) + XCTAssertNil(ChatExporter.exportableBody("only reasoning", format: .markdown)) + } + + func testExportableBodyKeepsMarkdownContentIntact() { + let input = "```swift\nlet x = 1\n```\n**bold** and | a | table |" + XCTAssertEqual(ChatExporter.exportableBody(input, format: .markdown), input) + } + + // MARK: - Full rendering + + func testRenderMarkdownTranscript() { + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let formattedTimestamp = ChatExporter.timestampFormatter.string(from: timestamp) + let result = ChatExporter.render( + chatName: "Test Chat", + systemMessage: "You are a helpful assistant.", + messages: [ + message("Hello!", isUser: true, timestamp: timestamp), + message("Hi, how can I help?", isUser: false, timestamp: timestamp), + ], + format: .markdown + ) + + let expected = """ + # Test Chat + + **System prompt:** + + > You are a helpful assistant. + + --- + + #### User — \(formattedTimestamp) + + Hello! + + --- + + #### Assistant — \(formattedTimestamp) + + Hi, how can I help? + + """ + XCTAssertEqual(result, expected) + } + + func testRenderPlainTextTranscript() { + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let formattedTimestamp = ChatExporter.timestampFormatter.string(from: timestamp) + let result = ChatExporter.render( + chatName: "Test Chat", + systemMessage: nil, + messages: [ + message("Question?", isUser: true, timestamp: timestamp), + message("Answer.", isUser: false, timestamp: timestamp), + ], + format: .plainText + ) + + let separator = String(repeating: "-", count: 40) + let expected = """ + Test Chat + ========= + + \(separator) + + User — \(formattedTimestamp): + Question? + + \(separator) + + Assistant — \(formattedTimestamp): + Answer. + + """ + XCTAssertEqual(result, expected) + } + + func testRenderSkipsEmptyMessages() { + let result = ChatExporter.render( + chatName: "Chat", + systemMessage: nil, + messages: [ + message("reasoning only", isUser: false), + message("Visible", isUser: true), + ], + format: .plainText + ) + XCTAssertFalse(result.contains("reasoning only")) + XCTAssertTrue(result.contains("Visible")) + } + + func testRenderMultilineSystemMessageIsFullyQuotedInMarkdown() { + let result = ChatExporter.render( + chatName: "Chat", + systemMessage: "Line one\nLine two", + messages: [], + format: .markdown + ) + XCTAssertTrue(result.contains("> Line one\n> Line two")) + } + + func testRenderWithEmptyChatNameOmitsTitle() { + let result = ChatExporter.render( + chatName: " ", + systemMessage: nil, + messages: [message("Hi", isUser: true)], + format: .markdown + ) + XCTAssertTrue(result.hasPrefix("#### User")) + XCTAssertTrue(result.contains("Hi")) + } + + // MARK: - Bulk export + + func testCombineMarkdownTranscriptsPreservesChatBoundaries() { + let result = ChatExporter.combine( + transcripts: ["# First\n\nMessage one\n", "# Second\n\nMessage two\n"], + format: .markdown + ) + + XCTAssertEqual(result, "# First\n\nMessage one\n\n---\n\n# Second\n\nMessage two\n") + } + + func testCombinePlainTextTranscriptsUsesVisibleSeparator() { + let result = ChatExporter.combine( + transcripts: ["First\n", "Second\n"], + format: .plainText + ) + + XCTAssertEqual(result, "First\n\n\(String(repeating: "=", count: 60))\n\nSecond\n") + } + + func testCombineTranscriptsSkipsEmptyChats() { + let result = ChatExporter.combine( + transcripts: ["", " \n", "# Only chat\n"], + format: .markdown + ) + + XCTAssertEqual(result, "# Only chat\n") + } + + func testBulkSuggestedFileNameIncludesCountAndFormat() { + XCTAssertEqual(ChatExporter.suggestedFileName(chatCount: 4, format: .markdown), "4 Chats.md") + XCTAssertEqual(ChatExporter.suggestedFileName(chatCount: 2, format: .plainText), "2 Chats.txt") + } +} diff --git a/macaiTests/Utilities/DatabaseMigrationTests.swift b/macaiTests/Utilities/DatabaseMigrationTests.swift index 63d8feb..a9ddab9 100644 --- a/macaiTests/Utilities/DatabaseMigrationTests.swift +++ b/macaiTests/Utilities/DatabaseMigrationTests.swift @@ -171,6 +171,96 @@ final class DatabaseMigrationTests: XCTestCase { XCTAssertEqual(export.documents.count, 0) } + func testModel3SQLiteStoreMigratesToModel4WithoutLosingChatData() throws { + guard let legacyModel = loadModelVersion(named: "macaiDataModel 3", remapClassNames: true), + let currentModel = loadCurrentModel() else { + throw XCTSkip("Required Core Data models not available in bundle") + } + + let baseURL = FileManager.default.temporaryDirectory + .appendingPathComponent("macai-v3-to-v4-\(UUID().uuidString)") + let sqliteURL = baseURL.appendingPathExtension("sqlite") + defer { removeStoreFiles(at: baseURL) } + + let chatID = UUID() + let serviceID = UUID() + let personaID = UUID() + + do { + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: legacyModel) + try coordinator.addPersistentStore( + ofType: NSSQLiteStoreType, + configurationName: nil, + at: sqliteURL, + options: [NSSQLitePragmasOption: ["journal_mode": "DELETE"]] + ) + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.persistentStoreCoordinator = coordinator + + try context.performAndWait { + let service = NSEntityDescription.insertNewObject(forEntityName: "APIServiceEntity", into: context) + service.setValue(serviceID, forKey: "id") + service.setValue("Legacy OpenAI", forKey: "name") + service.setValue("chatgpt", forKey: "type") + service.setValue("legacy-model", forKey: "model") + service.setValue(URL(string: "https://example.com/v1/chat/completions"), forKey: "url") + + let persona = NSEntityDescription.insertNewObject(forEntityName: "PersonaEntity", into: context) + persona.setValue(personaID, forKey: "id") + persona.setValue("Legacy Assistant", forKey: "name") + persona.setValue("Legacy system prompt", forKey: "systemMessage") + service.setValue(persona, forKey: "defaultPersona") + service.setValue(true, forKey: "isDefault") + + let chat = NSEntityDescription.insertNewObject(forEntityName: "ChatEntity", into: context) + chat.setValue(chatID, forKey: "id") + chat.setValue("Legacy Chat", forKey: "name") + chat.setValue("legacy-model", forKey: "gptModel") + chat.setValue("Legacy system prompt", forKey: "systemMessage") + chat.setValue("Unsaved draft", forKey: "draftMessage") + chat.setValue(Int64(0), forKey: "lastSequence") + chat.setValue(service, forKey: "apiService") + chat.setValue(persona, forKey: "persona") + + try context.save() + } + } + + let container = NSPersistentContainer(name: "macaiDataModel", managedObjectModel: currentModel) + let description = NSPersistentStoreDescription(url: sqliteURL) + description.shouldMigrateStoreAutomatically = true + description.shouldInferMappingModelAutomatically = true + container.persistentStoreDescriptions = [description] + + let loadExpectation = expectation(description: "Load and migrate Model 3 store") + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error + loadExpectation.fulfill() + } + wait(for: [loadExpectation], timeout: 10) + XCTAssertNil(loadError) + + let request = NSFetchRequest(entityName: "ChatEntity") + request.predicate = NSPredicate(format: "id == %@", chatID as CVarArg) + let migratedChat = try XCTUnwrap(container.viewContext.fetch(request).first) + XCTAssertEqual(migratedChat.value(forKey: "name") as? String, "Legacy Chat") + XCTAssertEqual(migratedChat.value(forKey: "draftMessage") as? String, "Unsaved draft") + XCTAssertEqual((migratedChat.value(forKey: "apiService") as? NSManagedObject)?.value(forKey: "id") as? UUID, serviceID) + XCTAssertEqual((migratedChat.value(forKey: "persona") as? NSManagedObject)?.value(forKey: "id") as? UUID, personaID) + XCTAssertEqual(migratedChat.value(forKey: "apiServiceOverridden") as? Bool, false) + XCTAssertEqual(migratedChat.value(forKey: "modelOverridden") as? Bool, false) + XCTAssertEqual(migratedChat.value(forKey: "systemMessageOverridden") as? Bool, false) + + let migratedService = try XCTUnwrap(migratedChat.value(forKey: "apiService") as? NSManagedObject) + let legacyDefault = migratedService.value(forKey: "defaultPersona") as? NSManagedObject + XCTAssertEqual(legacyDefault?.value(forKey: "id") as? UUID, personaID) + XCTAssertEqual( + (migratedChat.value(forKey: "persona") as? NSManagedObject)?.value(forKey: "isDefault") as? Bool, + false + ) + } + // MARK: - Helpers private struct StoreURLs { diff --git a/macaiTests/Utilities/DatabasePatcherTests.swift b/macaiTests/Utilities/DatabasePatcherTests.swift new file mode 100644 index 0000000..e2173ea --- /dev/null +++ b/macaiTests/Utilities/DatabasePatcherTests.swift @@ -0,0 +1,251 @@ +// +// DatabasePatcherTests.swift +// macaiTests +// +// Created by Renat Notfullin on 07.07.2026. +// + +import CoreData +import XCTest + +@testable import macai + +/// Tests for the assistant-settings migration that runs when users update +/// from the service-centric model (v3) to the persona-centric model (v4). +final class DatabasePatcherTests: XCTestCase { + + private var container: NSPersistentContainer! + private var context: NSManagedObjectContext! + + override func setUpWithError() throws { + try super.setUpWithError() + + ValueTransformer.setValueTransformer( + RequestMessagesTransformer(), + forName: RequestMessagesTransformer.name + ) + + guard let model = NSManagedObjectModel.mergedModel(from: [Bundle(for: ChatEntity.self)]) else { + throw XCTSkip("Core Data model not found in bundle") + } + + container = NSPersistentContainer(name: "macaiDataModel", managedObjectModel: model) + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + container.persistentStoreDescriptions = [description] + + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error + } + if let loadError { + throw loadError + } + context = container.viewContext + } + + override func tearDownWithError() throws { + context = nil + container = nil + try super.tearDownWithError() + } + + // MARK: - Fixtures (simulate pre-migration, v3-shaped data) + + private func makeService(name: String, model: String = "gpt-4o") -> APIServiceEntity { + let service = APIServiceEntity(context: context) + service.id = UUID() + service.name = name + service.type = "chatgpt" + service.url = URL(string: "https://api.openai.com/v1/chat/completions") + service.model = model + service.contextSize = 20 + service.generateChatNames = true + service.useStreamResponse = true + service.addedDate = Date() + return service + } + + private func makePersona(name: String) -> PersonaEntity { + let persona = PersonaEntity(context: context) + persona.id = UUID() + persona.name = name + persona.systemMessage = "You are \(name)." + persona.temperature = 0.7 + persona.addedDate = Date() + // v3 personas had no apiService, model, or per-persona settings + persona.apiService = nil + persona.model = nil + persona.contextSize = 0 + return persona + } + + private func makeChat(persona: PersonaEntity?, service: APIServiceEntity?) -> ChatEntity { + let chat = ChatEntity(context: context) + chat.id = UUID() + chat.createdDate = Date() + chat.updatedDate = Date() + chat.systemMessage = "Chat system message" + chat.gptModel = "gpt-4o" + chat.name = "Test Chat" + chat.lastSequence = 0 + chat.persona = persona + chat.apiService = service + return chat + } + + private func addMessage(_ body: String, to chat: ChatEntity, own: Bool) { + let message = MessageEntity(context: context) + message.body = body + message.own = own + message.timestamp = Date() + chat.addToMessages(message) + } + + // MARK: - Tests + + func testMigrationOnEmptyStoreCompletes() { + // A no-op migration must report success so it is not re-run on every launch. + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + } + + func testMigrationBindsPersonaToSingleCandidateService() throws { + let service = makeService(name: "OpenAI") + let persona = makePersona(name: "Writer") + let chat = makeChat(persona: persona, service: service) + addMessage("Hello", to: chat, own: true) + try context.save() + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + + XCTAssertEqual(persona.apiService?.objectID, service.objectID) + XCTAssertEqual(persona.model, "gpt-4o") + XCTAssertEqual(persona.contextSize, 20) + XCTAssertEqual(chat.persona?.objectID, persona.objectID) + XCTAssertEqual(chat.apiService?.objectID, service.objectID) + } + + func testMigrationClonesPersonaUsedWithMultipleServicesAndPreservesChatServices() throws { + let serviceA = makeService(name: "OpenAI") + let serviceB = makeService(name: "Claude") + let persona = makePersona(name: "Shared") + serviceA.isDefault = true + serviceA.defaultPersona = persona + serviceB.defaultPersona = persona + let chatA = makeChat(persona: persona, service: serviceA) + let chatB = makeChat(persona: persona, service: serviceB) + try context.save() + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + + let migratedPersonaA = try XCTUnwrap(chatA.persona) + let migratedPersonaB = try XCTUnwrap(chatB.persona) + XCTAssertNotEqual(migratedPersonaA.objectID, migratedPersonaB.objectID) + XCTAssertEqual(migratedPersonaA.apiService?.objectID, serviceA.objectID) + XCTAssertEqual(migratedPersonaB.apiService?.objectID, serviceB.objectID) + XCTAssertNil(serviceA.defaultPersona) + XCTAssertNil(serviceB.defaultPersona) + XCTAssertTrue(migratedPersonaA.isDefault) + XCTAssertFalse(migratedPersonaB.isDefault) + XCTAssertEqual(chatA.apiService?.objectID, serviceA.objectID) + XCTAssertEqual(chatB.apiService?.objectID, serviceB.objectID) + XCTAssertFalse(chatA.apiServiceOverridden) + XCTAssertFalse(chatB.apiServiceOverridden) + + let personaCount = try context.count(for: PersonaEntity.fetchRequest()) + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + XCTAssertEqual(try context.count(for: PersonaEntity.fetchRequest()), personaCount) + XCTAssertEqual(chatA.apiService?.objectID, serviceA.objectID) + XCTAssertEqual(chatB.apiService?.objectID, serviceB.objectID) + } + + func testMigrationCreatesDefaultPersonaForServiceWithoutOne() throws { + let service = makeService(name: "Mistral") + try context.save() + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + + let request = NSFetchRequest(entityName: "PersonaEntity") + let defaultPersona = try XCTUnwrap(try context.fetch(request).first) + XCTAssertEqual(defaultPersona.apiService?.objectID, service.objectID) + XCTAssertEqual(defaultPersona.model, "gpt-4o") + XCTAssertTrue(defaultPersona.isDefault) + } + + func testMigrationAssignsDefaultPersonaToChatWithoutPersona() throws { + let service = makeService(name: "OpenAI") + let chat = makeChat(persona: nil, service: service) + try context.save() + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + + XCTAssertNotNil(chat.persona) + XCTAssertEqual(chat.persona?.apiService?.objectID, service.objectID) + XCTAssertTrue(chat.persona?.isDefault ?? false) + } + + func testMigrationRestoresServiceForChatWithBoundPersonaOnly() throws { + let service = makeService(name: "OpenAI") + let persona = makePersona(name: "Coder") + persona.apiService = service + let chat = makeChat(persona: persona, service: nil) + try context.save() + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + + XCTAssertEqual(chat.apiService?.objectID, service.objectID) + } + + func testMigrationPreservesChatContent() throws { + let service = makeService(name: "OpenAI") + let persona = makePersona(name: "Writer") + let chat = makeChat(persona: persona, service: service) + addMessage("Question", to: chat, own: true) + addMessage("Answer", to: chat, own: false) + try context.save() + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + + XCTAssertEqual(chat.name, "Test Chat") + XCTAssertEqual(chat.systemMessage, "Chat system message") + XCTAssertEqual(chat.messagesArray.count, 2) + XCTAssertEqual(chat.messagesArray.map(\.body), ["Question", "Answer"]) + } + + func testMigrationMarksLegacyPerChatSettingsAsOverrides() throws { + let service = makeService(name: "OpenAI") + let persona = makePersona(name: "Writer") + persona.apiService = service + persona.model = "assistant-model" + persona.systemMessage = "Assistant system message" + let chat = makeChat(persona: persona, service: service) + chat.gptModel = "chat-model" + chat.systemMessage = "Chat-specific system message" + try context.save() + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + + XCTAssertFalse(chat.apiServiceOverridden) + XCTAssertTrue(chat.modelOverridden) + XCTAssertTrue(chat.systemMessageOverridden) + XCTAssertEqual(chat.effectiveModel, "chat-model") + XCTAssertEqual(chat.effectiveSystemMessage, "Chat-specific system message") + } + + func testMigrationIsIdempotent() throws { + let service = makeService(name: "OpenAI") + let persona = makePersona(name: "Writer") + let chat = makeChat(persona: persona, service: service) + try context.save() + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + let personaCountAfterFirstRun = try context.count(for: PersonaEntity.fetchRequest()) + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + let personaCountAfterSecondRun = try context.count(for: PersonaEntity.fetchRequest()) + + XCTAssertEqual(personaCountAfterFirstRun, personaCountAfterSecondRun) + XCTAssertEqual(persona.apiService?.objectID, service.objectID) + XCTAssertEqual(chat.persona?.objectID, persona.objectID) + } +} diff --git a/macaiTests/Utilities/ExtensionsTests.swift b/macaiTests/Utilities/ExtensionsTests.swift new file mode 100644 index 0000000..5989638 --- /dev/null +++ b/macaiTests/Utilities/ExtensionsTests.swift @@ -0,0 +1,52 @@ +// +// ExtensionsTests.swift +// macaiTests +// +// Created by Renat Notfullin on 07.07.2026. +// + +import XCTest + +@testable import macai + +final class ExtensionsTests: XCTestCase { + + // MARK: - Double.toInt16 + + func testToInt16WithinBounds() { + XCTAssertEqual(Double(0).toInt16(), 0) + XCTAssertEqual(Double(15).toInt16(), 15) + XCTAssertEqual(Double(-20).toInt16(), -20) + XCTAssertEqual(Double(Int16.max).toInt16(), Int16.max) + XCTAssertEqual(Double(Int16.min).toInt16(), Int16.min) + } + + func testToInt16OutOfBoundsReturnsNil() { + XCTAssertNil(Double(Int16.max) .advanced(by: 1).toInt16()) + XCTAssertNil(Double(Int16.min).advanced(by: -1).toInt16()) + XCTAssertNil(Double.greatestFiniteMagnitude.toInt16()) + } + + // MARK: - Float.roundedToOneDecimal + + func testRoundedToOneDecimal() { + XCTAssertEqual(Float(0.749).roundedToOneDecimal(), 0.7) + XCTAssertEqual(Float(0.75).roundedToOneDecimal(), 0.8) + XCTAssertEqual(Float(1.0).roundedToOneDecimal(), 1.0) + // .rounded() rounds away from zero at the midpoint + XCTAssertEqual(Float(-0.35).roundedToOneDecimal(), -0.4, accuracy: 0.0001) + } + + // MARK: - SHA256 + + func testStringSHA256KnownVector() { + XCTAssertEqual( + "abc".sha256(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ) + XCTAssertEqual( + "".sha256(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + } +} diff --git a/macaiTests/Utilities/GeminiHandlerTests.swift b/macaiTests/Utilities/GeminiHandlerTests.swift index 25b70b1..e7f8ff9 100644 --- a/macaiTests/Utilities/GeminiHandlerTests.swift +++ b/macaiTests/Utilities/GeminiHandlerTests.swift @@ -193,6 +193,124 @@ final class GeminiHandlerTests: XCTestCase { XCTAssertNil(signaturePart?["text"]) XCTAssertNil(signaturePart?["inlineData"]) } + + func testOpenAIResponsesHandlerUsesInputTextForAssistantHistory() throws { + let expectation = expectation(description: "Request captured") + var capturedRequest: URLRequest? + + URLProtocolStub.requestHandler = { request in + capturedRequest = request + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, Data("{\"output_text\":\"ok\"}".utf8)) + } + + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [URLProtocolStub.self] + let session = URLSession(configuration: sessionConfig) + let config = APIServiceConfig( + name: "OpenAI Responses", + apiUrl: URL(string: "https://example.com/v1/responses")!, + apiKey: "test-key", + model: "test-model" + ) + let handler = OpenAIResponsesHandler(config: config, session: session) + + handler.sendMessage( + [ + ["role": "assistant", "content": "Previous answer"], + ["role": "user", "content": "Follow-up question"], + ], + temperature: 1.0 + ) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + + guard let request = capturedRequest, + let body = requestBody(from: request), + let payload = try JSONSerialization.jsonObject(with: body) as? [String: Any], + let input = payload["input"] as? [[String: Any]], + let assistant = input.first(where: { $0["role"] as? String == "assistant" }), + let content = assistant["content"] as? [[String: Any]] + else { + XCTFail("Unable to decode assistant input content") + return + } + + XCTAssertEqual(content.first?["type"] as? String, "input_text") + XCTAssertFalse(content.contains { $0["type"] as? String == "output_text" }) + } + + func testPerplexityConnectionTestUsesModelIndependentRequest() async throws { + var capturedRequest: URLRequest? + URLProtocolStub.requestHandler = { request in + capturedRequest = request + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: 405, + httpVersion: nil, + headerFields: nil + )! + return (response, Data()) + } + + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [URLProtocolStub.self] + let session = URLSession(configuration: sessionConfig) + let config = APIServiceConfig( + name: "Perplexity", + apiUrl: URL(string: "https://api.perplexity.ai/chat/completions")!, + apiKey: "test-key", + model: "" + ) + + let result = try await PerplexityHandler(config: config, session: session).testConnection() + + XCTAssertFalse(result.authenticationVerified) + XCTAssertNil(result.discoveredModelCount) + XCTAssertEqual(capturedRequest?.httpMethod, "GET") + XCTAssertEqual(capturedRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-key") + XCTAssertNil(capturedRequest?.httpBody) + } + + func testPerplexityConnectionTestRejectsUnauthorizedKey() async { + URLProtocolStub.requestHandler = { request in + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: 401, + httpVersion: nil, + headerFields: nil + )! + return (response, Data()) + } + + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [URLProtocolStub.self] + let session = URLSession(configuration: sessionConfig) + let config = APIServiceConfig( + name: "Perplexity", + apiUrl: URL(string: "https://api.perplexity.ai/chat/completions")!, + apiKey: "invalid-key", + model: "" + ) + + do { + _ = try await PerplexityHandler(config: config, session: session).testConnection() + XCTFail("Expected an unauthorized error") + } + catch APIError.unauthorized { + // Expected. + } + catch { + XCTFail("Unexpected error: \(error)") + } + } } private final class URLProtocolStub: URLProtocol { diff --git a/macaiTests/Utilities/MessageManagerTests.swift b/macaiTests/Utilities/MessageManagerTests.swift new file mode 100644 index 0000000..a24512a --- /dev/null +++ b/macaiTests/Utilities/MessageManagerTests.swift @@ -0,0 +1,40 @@ +// +// MessageManagerTests.swift +// macaiTests +// +// Created by Renat Notfullin on 07.07.2026. +// + +import XCTest + +@testable import macai + +final class MessageManagerTests: XCTestCase { + + // MARK: - sanitizeChatName + + func testSanitizeChatNameExtractsBoldMarkdownName() { + XCTAssertEqual(MessageManager.sanitizeChatName("**Trip Planning**"), "Trip Planning") + XCTAssertEqual( + MessageManager.sanitizeChatName("Here is a name:\n**Swift Questions**\nHope you like it"), + "Swift Questions" + ) + } + + func testSanitizeChatNameUsesLastNonEmptyLine() { + let reasoningStyleReply = """ + Let me think of a good name. + + Weather Chat + """ + XCTAssertEqual(MessageManager.sanitizeChatName(reasoningStyleReply), "Weather Chat") + } + + func testSanitizeChatNameTrimsWhitespace() { + XCTAssertEqual(MessageManager.sanitizeChatName(" Simple Name "), "Simple Name") + } + + func testSanitizeChatNamePlainSingleLine() { + XCTAssertEqual(MessageManager.sanitizeChatName("Grocery List Help"), "Grocery List Help") + } +} diff --git a/macaiTests/Utilities/ModelsCacheTests.swift b/macaiTests/Utilities/ModelsCacheTests.swift new file mode 100644 index 0000000..c971804 --- /dev/null +++ b/macaiTests/Utilities/ModelsCacheTests.swift @@ -0,0 +1,78 @@ +// +// ModelsCacheTests.swift +// macaiTests +// +// Created by Renat Notfullin on 07.07.2026. +// + +import XCTest + +@testable import macai + +final class ModelsCacheTests: XCTestCase { + + private var defaults: UserDefaults! + private var cache: ModelsCache! + private let suiteName = "ModelsCacheTests" + + override func setUpWithError() throws { + try super.setUpWithError() + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + cache = ModelsCache(defaults: defaults) + } + + override func tearDownWithError() throws { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + cache = nil + try super.tearDownWithError() + } + + func testReturnsNilWhenEmpty() { + XCTAssertNil(cache.models(for: UUID())) + } + + func testStoreAndRetrieve() { + let serviceID = UUID() + cache.store(["gpt-4o", "gpt-4o-mini"], for: serviceID) + XCTAssertEqual(cache.models(for: serviceID), ["gpt-4o", "gpt-4o-mini"]) + } + + func testCachePersistsAcrossInstances() { + let serviceID = UUID() + cache.store(["claude-sonnet-5"], for: serviceID) + + let freshInstance = ModelsCache(defaults: defaults) + XCTAssertEqual(freshInstance.models(for: serviceID), ["claude-sonnet-5"]) + } + + func testEmptyListIsNotStored() { + let serviceID = UUID() + cache.store([], for: serviceID) + XCTAssertNil(cache.models(for: serviceID)) + } + + func testStoreOverwritesPreviousList() { + let serviceID = UUID() + cache.store(["old-model"], for: serviceID) + cache.store(["new-model"], for: serviceID) + XCTAssertEqual(cache.models(for: serviceID), ["new-model"]) + } + + func testCachesAreIsolatedPerService() { + let first = UUID() + let second = UUID() + cache.store(["model-a"], for: first) + cache.store(["model-b"], for: second) + XCTAssertEqual(cache.models(for: first), ["model-a"]) + XCTAssertEqual(cache.models(for: second), ["model-b"]) + } + + func testRemoveModels() { + let serviceID = UUID() + cache.store(["model"], for: serviceID) + cache.removeModels(for: serviceID) + XCTAssertNil(cache.models(for: serviceID)) + } +} diff --git a/macaiTests/Utilities/RequestMessagesTransformerTests.swift b/macaiTests/Utilities/RequestMessagesTransformerTests.swift new file mode 100644 index 0000000..3d5a167 --- /dev/null +++ b/macaiTests/Utilities/RequestMessagesTransformerTests.swift @@ -0,0 +1,46 @@ +// +// RequestMessagesTransformerTests.swift +// macaiTests +// +// Created by Renat Notfullin on 07.07.2026. +// + +import XCTest + +@testable import macai + +final class RequestMessagesTransformerTests: XCTestCase { + + private let transformer = RequestMessagesTransformer() + + func testRoundTripPreservesMessages() { + let messages: [[String: String]] = [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "Hello, world! Ünïcödé 🚀"], + ["role": "assistant", "content": "Hi there."], + ] + + let archived = transformer.transformedValue(messages) + XCTAssertNotNil(archived) + + let restored = transformer.reverseTransformedValue(archived) as? [[String: String]] + XCTAssertEqual(restored, messages) + } + + func testRoundTripPreservesEmptyArray() { + let archived = transformer.transformedValue([[String: String]]()) + let restored = transformer.reverseTransformedValue(archived) as? [[String: String]] + XCTAssertEqual(restored, []) + } + + func testTransformedValueReturnsNilForUnsupportedInput() { + XCTAssertNil(transformer.transformedValue("not an array")) + XCTAssertNil(transformer.transformedValue(nil)) + } + + func testReverseTransformedValueReturnsNilForInvalidData() { + XCTAssertNil(transformer.reverseTransformedValue("not data")) + XCTAssertNil(transformer.reverseTransformedValue(nil)) + XCTAssertNil(transformer.reverseTransformedValue(Data([0x00, 0x01, 0x02]))) + } +}