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 ";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)>(.*?)\(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)")
+ }
+
+ 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])))
+ }
+}