From 4f704a3d79f493298e0ed93e3a8a6e768deb8e09 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Tue, 7 Jul 2026 19:14:03 +0300 Subject: [PATCH 01/23] =?UTF-8?q?=E2=9C=A8=20Release=203.0.0:=20assistant-?= =?UTF-8?q?centric=20settings,=20chat=20inspector,=20export,=20and=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI Assistants now own model, temperature, context size, and tools; each assistant binds to an API service (data model v4, additive migration with idempotent DatabasePatcher pass and tests) - New Assistant Settings panel (HSplitView pane) in chat with cached model lists (ModelsCache), loading indicators, and stale-response guards - Chat export to Markdown/plain text with embedded images, from the chat list context menu and the Chat menu (disabled when no chat is selected) - Search-in-chat applies when switching chats with an active query - Reliable scroll-to-bottom on chat open (bottom sentinel + settle window) - Fixed: Regenerate Name for named chats, assistant stealing when saving an API service, Link force-unwrap crash, Stop targeting a recreated manager, duplicated key monitors, redundant Core Data writes on inspector open - Export renders off the main thread; assistant edits are debounced before saving to reduce CloudKit sync churn Co-Authored-By: Claude Fable 5 --- .gitignore | 9 + AGENTS.md | 95 ++++ README.md | 3 + docs/PRD_CoreData_V3_Migration.md | 164 +++++++ macai.xcodeproj/project.pbxproj | 48 +- macai/Configuration/AppConstants.swift | 2 + macai/Models/Models.swift | 62 ++- macai/Models/RequestMessagesTransformer.swift | 5 +- macai/Store/ChatStore.swift | 7 +- .../.xccurrentversion | 2 +- .../macaiDataModel 4.xcdatamodel/contents | 93 ++++ macai/UI/Chat/AssistantInspectorView.swift | 435 ++++++++++++++++++ .../BottomContainer/MessageInputView.swift | 6 +- .../BottomContainer/PersonaSelectorView.swift | 7 + macai/UI/Chat/ChatLogicHandler.swift | 22 +- macai/UI/Chat/ChatMessagesView.swift | 111 +++-- macai/UI/Chat/ChatView.swift | 53 ++- macai/UI/Chat/ChatViewModel.swift | 130 ++++-- macai/UI/Chat/CodeView/CodeView.swift | 10 +- macai/UI/ChatList/ChatListRow.swift | 22 +- macai/UI/ChatList/ChatListView.swift | 2 +- macai/UI/ContentView.swift | 310 +++++++------ macai/UI/Preferences/TabAIPersonasView.swift | 252 +++++++++- .../TabAPIServices/APIServiceDetailView.swift | 172 +------ .../APIServiceDetailViewModel.swift | 200 +++----- .../APIServiceTemplateAddView.swift | 4 +- .../APIServiceTemplateAddViewModel.swift | 55 ++- .../ButtonTestApiTokenAndModel.swift | 11 +- macai/UI/Preferences/TabAPIServicesView.swift | 29 +- .../Preferences/TabGeneralSettingsView.swift | 4 +- .../APIHandlers/ChatGPTHandler.swift | 4 +- .../Utilities/APIHandlers/ClaudeHandler.swift | 5 +- macai/Utilities/APIServiceManager.swift | 101 +++- macai/Utilities/ChatExporter.swift | 312 +++++++++++++ macai/Utilities/DatabasePatcher.swift | 219 ++++++++- macai/Utilities/Extensions.swift | 25 + macai/Utilities/MessageManager.swift | 20 +- macai/Utilities/ModelsCache.swift | 56 +++ macai/macaiApp.swift | 49 +- macaiTests/Utilities/ChatEntityTests.swift | 163 +++++++ macaiTests/Utilities/ChatExporterTests.swift | 224 +++++++++ .../Utilities/DatabasePatcherTests.swift | 212 +++++++++ macaiTests/Utilities/ExtensionsTests.swift | 52 +++ .../Utilities/MessageManagerTests.swift | 40 ++ macaiTests/Utilities/ModelsCacheTests.swift | 78 ++++ .../RequestMessagesTransformerTests.swift | 46 ++ 46 files changed, 3284 insertions(+), 647 deletions(-) create mode 100644 AGENTS.md create mode 100755 docs/PRD_CoreData_V3_Migration.md create mode 100644 macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents create mode 100644 macai/UI/Chat/AssistantInspectorView.swift create mode 100644 macai/Utilities/ChatExporter.swift create mode 100644 macai/Utilities/ModelsCache.swift create mode 100644 macaiTests/Utilities/ChatEntityTests.swift create mode 100644 macaiTests/Utilities/ChatExporterTests.swift create mode 100644 macaiTests/Utilities/DatabasePatcherTests.swift create mode 100644 macaiTests/Utilities/ExtensionsTests.swift create mode 100644 macaiTests/Utilities/MessageManagerTests.swift create mode 100644 macaiTests/Utilities/ModelsCacheTests.swift create mode 100644 macaiTests/Utilities/RequestMessagesTransformerTests.swift 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..9928d1e 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,9 @@ macOS 14.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..d38517b 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 /* AssistantInspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70CC51F011F74CE497CC83D9 /* AssistantInspectorView.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,15 @@ 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 */; }; + 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 */; }; @@ -163,6 +173,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 /* AssistantInspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantInspectorView.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 +185,21 @@ 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 = ""; }; + 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 = ""; }; @@ -345,6 +366,7 @@ children = ( 4AC37B622F1255C0006FD196 /* AttachmentPreviewViews.swift */, 4AC37B7D2F14000100D1A2B3 /* ChatDraftManager.swift */, + 70CC51F011F74CE497CC83D9 /* AssistantInspectorView.swift */, 4A1E16832E16DB4500E5CBA3 /* ChatInputView.swift */, 4A1E16842E16DB4500E5CBA3 /* ChatMessagesView.swift */, 4A1E16822E16DB4500E5CBA3 /* ChatLogicHandler.swift */, @@ -453,6 +475,8 @@ 3EA7767A135E47FFAB9E3289 /* APIServiceTemplateProvider.swift */, 4AFEE71F2C472F29006F99FB /* APIHandlers */, 4ABDBE6529F86B5900661B2D /* MessageParser.swift */, + C4E7B0A12E1A000100000001 /* ChatExporter.swift */, + C4E7B0A12E1A00010000000F /* ModelsCache.swift */, 4A775A032C1486F3004301D3 /* Extensions.swift */, B2681BF4ED394B949A864169 /* PreviewFileHelper.swift */, 090CED78E6224CCB84778089 /* QuickLookPreviewer.swift */, @@ -541,6 +565,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 */, @@ -739,6 +770,8 @@ 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 */, 0BA0FF405FC14D3FABF82621 /* APIServiceTemplateProvider.swift in Sources */, 4ACE2FB82D61656000DEEA6D /* SwipeModifier.swift in Sources */, 4A0BCBBC2D5199140033AB96 /* ButtonWithStatusIndicator.swift in Sources */, @@ -800,6 +833,7 @@ 4A1E16852E16DB4500E5CBA3 /* ChatInputView.swift in Sources */, 4A1E16862E16DB4500E5CBA3 /* ChatMessagesView.swift in Sources */, 4A1E16872E16DB4600E5CBA3 /* ChatLogicHandler.swift in Sources */, + 9BC46E70111B408B9B189BE1 /* AssistantInspectorView.swift in Sources */, 4AFEE7252C472FB1006F99FB /* OllamaHandler.swift in Sources */, 4AE8691B2D5696A300E3B3AC /* GeminiHandler.swift in Sources */, ); @@ -810,6 +844,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 */, @@ -988,7 +1029,7 @@ 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; + MARKETING_VERSION = 3.0.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = notfullin.com.macai; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1031,7 +1072,7 @@ 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; + MARKETING_VERSION = 3.0.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = notfullin.com.macai; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1253,8 +1294,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/Configuration/AppConstants.swift b/macai/Configuration/AppConstants.swift index 75d96a0..181b5bd 100644 --- a/macai/Configuration/AppConstants.swift +++ b/macai/Configuration/AppConstants.swift @@ -55,6 +55,7 @@ struct AppConstants { static let openRouterUploadsPatchCompletedKey = "OpenRouterUploadsPatchCompleted" static let apiServiceMigrationCompletedKey = "APIServiceMigrationCompleted" static let defaultApiServiceMigrationCompletedKey = "DefaultAPIServiceMigrationCompleted" + static let assistantSettingsMigrationCompletedKey = "AssistantSettingsMigrationCompleted" static let draftTransactionAuthor = "Drafts" static let entityIDBackfillCompletedKey = "EntityIDBackfillCompleted" static let messageSequenceBackfillCompletedKey = "MessageSequenceBackfillCompleted" @@ -321,6 +322,7 @@ struct AppConstants { "openai-responses", "chatgpt", "ollama", "claude", "xai", "gemini", "perplexity", "deepseek", "openrouter", ] static let newChatNotification = Notification.Name("newChatNotification") + static let exportChatNotification = Notification.Name("exportChatNotification") static let largeMessageSymbolsThreshold = 25000 static let thumbnailSize: CGFloat = 300 static let maxHighlightableTextLength = 100000 diff --git a/macai/Models/Models.swift b/macai/Models/Models.swift index 177c3e7..ff133b0 100644 --- a/macai/Models/Models.swift +++ b/macai/Models/Models.swift @@ -302,20 +302,72 @@ extension ChatEntity { } } +extension ChatEntity { + var effectiveApiService: APIServiceEntity? { + persona?.apiService ?? apiService + } + + var effectiveModel: String { + let service = effectiveApiService + let defaultModel = AppConstants.defaultModel(for: service?.type) + let chatModel = gptModel.trimmingCharacters(in: .whitespacesAndNewlines) + let personaModel = persona?.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let personaServiceID = persona?.apiService?.objectID + let serviceID = service?.objectID + let personaMatchesService = personaServiceID == nil ? serviceID == nil : personaServiceID == serviceID + + if personaMatchesService, personaModel.isEmpty == false { + return personaModel + } + if chatModel.isEmpty == false { + return chatModel + } + return defaultModel + } + + var effectiveContextSize: Int { + let raw = Int(persona?.contextSize ?? 0) + if raw > 0 { + return raw + } + return Int(AppConstants.chatGptContextSize) + } + + var shouldUseStreamResponse: Bool { + persona?.useStreamResponse ?? true + } + + var shouldGenerateChatNames: Bool { + persona?.generateChatNames ?? true + } + + var imageGenerationEnabled: Bool { + persona?.imageGenerationEnabled ?? false + } + + var supportsImageGeneration: Bool { + (effectiveApiService?.imageGenerationSupported ?? false) && imageGenerationEnabled + } + + 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 + copy.defaultPersona = nil copy.isDefault = false copy.id = UUID() copy.tokenIdentifier = UUID().uuidString 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..675c7e4 100644 --- a/macai/Store/ChatStore.swift +++ b/macai/Store/ChatStore.swift @@ -151,11 +151,11 @@ class ChatStore: ObservableObject { } } + try viewContext.save() + DispatchQueue.main.async { completion(.success(chats.count)) } - - try viewContext.save() } catch { DispatchQueue.main.async { @@ -244,9 +244,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/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..fa67af3 --- /dev/null +++ b/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macai/UI/Chat/AssistantInspectorView.swift b/macai/UI/Chat/AssistantInspectorView.swift new file mode 100644 index 0000000..3e10aa5 --- /dev/null +++ b/macai/UI/Chat/AssistantInspectorView.swift @@ -0,0 +1,435 @@ +// +// AssistantInspectorView.swift +// macai +// +// Created by Codex on 16.01.2026. +// + +import AppKit +import CoreData +import SwiftUI + +struct AssistantInspectorView: 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 supportsImageGeneration: Bool { + chat.effectiveApiService?.imageGenerationSupported ?? false + } + + var body: some View { + content + } + + @ViewBuilder + private var content: some View { + VStack(alignment: .leading, spacing: 12) { + headerSection + + if let persona = chat.persona { + personaSettings(persona) + } else { + emptyStateSection + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + .padding(.top, 36) + .frame(minWidth: 260, idealWidth: 320, maxWidth: 420) + .background(Color(NSColor.windowBackgroundColor)) + .onAppear { + syncServiceSelection() + // Only persist when the stored value is actually stale. + if supportsImageGeneration == false, chat.persona?.imageGenerationEnabled == true { + chat.persona?.imageGenerationEnabled = false + viewContext.saveWithRetry(attempts: 1) + } + refreshModels() + } + .onDisappear { + flushPendingSave() + } + .onChange(of: chat.persona?.objectID) { _, _ in + flushPendingSave() + syncServiceSelection() + refreshModels() + } + .onChange(of: chat.effectiveApiService?.objectID) { _, _ in + syncServiceSelection() + refreshModels() + } + } + + private func syncServiceSelection() { + let currentID = chat.effectiveApiService?.objectID + if let currentID, + apiServices.contains(where: { $0.objectID == currentID }) { + selectedServiceID = currentID + } else { + selectedServiceID = nil + } + } + + private var headerSection: some View { + HStack { + Text("Assistant Settings") + .font(.headline) + Spacer() + } + } + + @ViewBuilder + private func personaSettings(_ persona: PersonaEntity) -> some View { + personaHeaderSection(persona) + Divider() + apiServiceSection + Divider() + modelSection(persona) + Divider() + temperatureSection(persona) + Divider() + contextSizeSection(persona) + Divider() + toolsSection(persona) + Spacer() + } + + private func personaHeaderSection(_ persona: PersonaEntity) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(persona.name ?? "Assistant") + .font(.title3) + Text(chat.effectiveApiService?.name ?? "No API Service") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + 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 func modelSection(_ persona: PersonaEntity) -> 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(for: persona)) + .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 = persona.model ?? "" + } + } else { + isCustomModel = false + customModel = newValue + updateModel(persona, value: newValue) + } + } + + if isCustomModel { + TextField("Enter custom model name", text: $customModel) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onChange(of: customModel) { _, newValue in + updateModel(persona, value: newValue) + } + } + } + } + } + + private func temperatureSection(_ persona: PersonaEntity) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Temperature") + .font(.subheadline) + .foregroundStyle(.secondary) + + Slider( + value: temperatureBinding(for: persona), + in: 0...1, + step: 0.1 + ) + HStack { + Text("0.0") + Spacer() + Text("\(String(format: "%.1f", persona.temperature)) · \(persona.temperature.temperatureDescriptor)") + Spacer() + Text("1.0") + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func contextSizeSection(_ persona: PersonaEntity) -> some View { + let effectiveContextSize = persona.contextSize == 0 + ? Int16(AppConstants.chatGptContextSize) + : persona.contextSize + + return VStack(alignment: .leading, spacing: 8) { + Text("Context size") + .font(.subheadline) + .foregroundStyle(.secondary) + + Slider( + value: contextSizeBinding(for: persona), + in: 5...100, + step: 5 + ) + + Text("\(Int(effectiveContextSize)) messages") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func toolsSection(_ persona: PersonaEntity) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Tools") + .font(.subheadline) + .foregroundStyle(.secondary) + + Toggle("Image generation", isOn: imageGenerationBinding(for: persona)) + .disabled(!supportsImageGeneration) + .help(supportsImageGeneration ? "" : "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) + } + } + + 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")") + } + + private var emptyStateSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Select an assistant to edit its settings.") + .foregroundStyle(.secondary) + Spacer() + } + } + + /// 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) + } + } + + private func temperatureBinding(for persona: PersonaEntity) -> Binding { + Binding( + get: { Double(persona.temperature) }, + set: { newValue in + persona.temperature = Float(newValue) + scheduleDebouncedSave() + } + ) + } + + private func contextSizeBinding(for persona: PersonaEntity) -> Binding { + Binding( + get: { Double(persona.contextSize == 0 ? Int16(AppConstants.chatGptContextSize) : persona.contextSize) }, + set: { newValue in + persona.contextSize = Int16(newValue) + scheduleDebouncedSave() + } + ) + } + + private func imageGenerationBinding(for persona: PersonaEntity) -> Binding { + Binding( + get: { persona.imageGenerationEnabled }, + set: { newValue in + persona.imageGenerationEnabled = newValue + viewContext.saveWithRetry(attempts: 1) + } + ) + } + + private func customModelBinding(for persona: PersonaEntity) -> Binding { + Binding( + get: { + customModel.isEmpty ? (persona.model ?? "") : customModel + }, + set: { newValue in + customModel = newValue + updateModel(persona, value: newValue) + } + ) + } + + private func updateModel(_ persona: PersonaEntity, value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard persona.model != trimmed else { return } + persona.model = trimmed + scheduleDebouncedSave() + } + + 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() { + guard let persona = chat.persona else { return } + let currentModel = persona.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let defaultModel = AppConstants.defaultModel(for: chat.effectiveApiService?.type) + + if availableModels.isEmpty { + selectedModel = currentModel + isCustomModel = true + customModel = currentModel + return + } + + if currentModel.isEmpty == false, availableModels.contains(currentModel) { + selectedModel = currentModel + isCustomModel = false + customModel = currentModel + } else if currentModel.isEmpty == false { + selectedModel = "custom" + isCustomModel = true + customModel = currentModel + } else if availableModels.contains(defaultModel) { + selectedModel = defaultModel + isCustomModel = false + customModel = defaultModel + } else if let firstModel = availableModels.first { + selectedModel = firstModel + isCustomModel = false + customModel = firstModel + } + } +} + +#Preview { + let context = PersistenceController.preview.container.viewContext + let chat = ChatEntity(context: context) + return AssistantInspectorView(chat: chat, onServiceChange: { _ in }) +} diff --git a/macai/UI/Chat/BottomContainer/MessageInputView.swift b/macai/UI/Chat/BottomContainer/MessageInputView.swift index 2dfcde3..5b38df6 100644 --- a/macai/UI/Chat/BottomContainer/MessageInputView.swift +++ b/macai/UI/Chat/BottomContainer/MessageInputView.swift @@ -288,10 +288,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 +300,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] = [] diff --git a/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift b/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift index 823580f..7dd7278 100644 --- a/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift +++ b/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift @@ -76,6 +76,13 @@ struct PersonaSelectorView: View { private func updatePersonaAndSystemMessage(to persona: PersonaEntity?) { chat.persona = persona chat.systemMessage = persona?.systemMessage ?? AppConstants.chatGptSystemMessage + 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 { 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..ceac5fc 100644 --- a/macai/UI/Chat/ChatMessagesView.swift +++ b/macai/UI/Chat/ChatMessagesView.swift @@ -20,10 +20,11 @@ struct ChatMessagesView: View { 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 initialLoadSettleWorkItem: DispatchWorkItem? @State private var isInitialLoad = true - + + private static let bottomAnchorID = "bottomAnchor" + var backgroundColor = Color(NSColor.controlBackgroundColor) private var shouldShowWaitingBubble: Bool { @@ -33,6 +34,7 @@ struct ChatMessagesView: View { var body: some View { ScrollView { ScrollViewReader { scrollView in + VStack(spacing: 0) { VStack { SystemMessageBubbleView( message: chat.systemMessage, @@ -94,17 +96,44 @@ struct ChatMessagesView: View { 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 + + // Invisible sentinel: the true bottom of the scrollable content. + // It sits below the list padding, so anchoring 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) + } + .background( + GeometryReader { geometry in + Color.clear.preference(key: ContentHeightPreferenceKey.self, value: geometry.size.height) } + ) + .onPreferenceChange(ContentHeightPreferenceKey.self) { _ in + // Async rendering (code highlighting, images) keeps changing the + // content height after the first layout pass. While the chat is + // still settling after being opened, re-pin to the bottom on + // every height change so we never stop short of the last message. + guard isInitialLoad, !userIsScrolling else { return } + // Pin on the next runloop turn so the scroll target is computed + // from the freshly committed layout, not mid-update metrics. + DispatchQueue.main.async { + guard isInitialLoad, !userIsScrolling else { return } + scrollView.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + scheduleInitialLoadSettle() + } + .onAppear { isInitialLoad = true - - if pendingCodeBlocks == 0 { - codeBlocksRendered = true - isInitialLoad = false + scheduleInitialLoadSettle() + } + .onChange(of: userIsScrolling) { _, isScrolling in + // The user took over; stop forcing the view to the bottom. + if isScrolling { + endInitialLoad() } } .onSwipe { event in @@ -115,15 +144,13 @@ struct ChatMessagesView: View { break } } - .onChange(of: chat.lastMessage?.body) { _ in + .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) - } + withAnimation(.easeOut(duration: 1)) { + scrollView.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } @@ -131,10 +158,10 @@ struct ChatMessagesView: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) } } - .onChange(of: chatViewModel.sortedMessages.count) { _ in + .onChange(of: chatViewModel.sortedMessages.count) { _, _ in if shouldShowWaitingBubble || currentError != nil { withAnimation { - scrollView.scrollTo(-1) + scrollView.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } } @@ -142,33 +169,14 @@ struct ChatMessagesView: View { 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) - } + withAnimation(.easeOut(duration: 0.5)) { + 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 @@ -200,4 +208,29 @@ struct ChatMessagesView: View { .allowsHitTesting(false) } } + + /// Ends the "initial load" phase once the content height has been stable + /// for a short quiet period, i.e. async rendering has finished. + private func scheduleInitialLoadSettle() { + initialLoadSettleWorkItem?.cancel() + let workItem = DispatchWorkItem { + isInitialLoad = false + } + initialLoadSettleWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1.25, execute: workItem) + } + + private func endInitialLoad() { + initialLoadSettleWorkItem?.cancel() + initialLoadSettleWorkItem = nil + isInitialLoad = false + } +} + +private struct ContentHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } } 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..b67f9be 100644 --- a/macai/UI/Chat/ChatViewModel.swift +++ b/macai/UI/Chat/ChatViewModel.swift @@ -49,26 +49,33 @@ 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 + private var lastObservedPersonaID: NSManagedObjectID? + private var lastObservedServiceID: NSManagedObjectID? - init(chat: ChatEntity, viewContext: NSManagedObjectContext) { + /// - 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 let fetchRequest: NSFetchRequest = MessageEntity.fetchRequest() as! NSFetchRequest fetchRequest.predicate = NSPredicate(format: "chat == %@", chat) @@ -83,6 +90,8 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg super.init() + guard loadsMessages else { return } + self.fetchedResultsController.delegate = self do { @@ -108,11 +117,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 +137,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 +153,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 +167,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 +183,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 +202,42 @@ 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) + 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 - guard changedServices.contains(where: { $0.objectID == serviceObjectID }) else { return } + guard personaObjectChanged || serviceObjectChanged || (chatObjectChanged && relationshipChanged) else { + return + } - self.applyAPIServiceChanges() + self.lastObservedPersonaID = personaID + self.lastObservedServiceID = serviceID + self.applyAPIServiceChanges(forceRecreate: personaObjectChanged || serviceObjectChanged || relationshipChanged) } } - 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 } @@ -209,23 +246,30 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg defer { self.isApplyingServiceChanges = false } var didChange = false - - let newModel = apiService.model ?? AppConstants.defaultModel(for: apiService.type) - if chat.gptModel != newModel { - chat.gptModel = newModel + let resolvedService = chat.persona?.apiService ?? chat.apiService + if let resolvedService, chat.apiService != resolvedService { + chat.apiService = resolvedService didChange = true } - // Ensure we keep the latest managed object instance (it may have been refreshed) - if chat.apiService != apiService { - chat.apiService = apiService + let fallbackModel = chat.gptModel.isEmpty + ? AppConstants.defaultModel(for: resolvedService?.type) + : chat.gptModel + let resolvedModel = chat.persona?.model?.isEmpty == false + ? (chat.persona?.model ?? fallbackModel) + : fallbackModel + + if chat.gptModel != resolvedModel { + chat.gptModel = resolvedModel didChange = true } - if didChange { + if didChange || forceRecreate { chat.objectWillChange.send() self.recreateMessageManager() - try? self.viewContext.save() + if didChange { + try? self.viewContext.save() + } } } } @@ -235,11 +279,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 +304,16 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg name: getApiServiceName(), apiUrl: apiServiceUrl, apiKey: apiKey, - model: chat.gptModel + model: chat.effectiveModel ) } private func getApiServiceName() -> String { - return chat.apiService?.type ?? AppConstants.defaultApiType + return chat.effectiveApiService?.type ?? AppConstants.defaultApiType } func regenerateChatName() { - messageManager.generateChatNameIfNeeded(chat: chat, force: true) + messageManager?.generateChatNameIfNeeded(chat: chat, force: true) } // MARK: - Search Logic diff --git a/macai/UI/Chat/CodeView/CodeView.swift b/macai/UI/Chat/CodeView/CodeView.swift index f29a7f7..8f12dcd 100644 --- a/macai/UI/Chat/CodeView/CodeView.swift +++ b/macai/UI/Chat/CodeView/CodeView.swift @@ -64,16 +64,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 +85,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..3569c06 100644 --- a/macai/UI/ChatList/ChatListRow.swift +++ b/macai/UI/ChatList/ChatListRow.swift @@ -53,7 +53,9 @@ struct ChatListRow: View, Equatable { self._selectedChat = selectedChat self.viewContext = viewContext self.searchText = searchText - self._chatViewModel = StateObject(wrappedValue: ChatViewModel(chat: chat, viewContext: viewContext)) + self._chatViewModel = StateObject( + wrappedValue: ChatViewModel(chat: chat, viewContext: viewContext, loadsMessages: false) + ) } var isActive: Binding { @@ -94,7 +96,7 @@ struct ChatListRow: View, Equatable { Button(action: { renameChat(chat) }) { Label("Rename", systemImage: "pencil") } - if chat.apiService?.generateChatNames ?? false { + if chat.shouldGenerateChatNames { Button(action: { chatViewModel.regenerateChatName() }) { @@ -104,6 +106,16 @@ struct ChatListRow: View, Equatable { Button(action: { clearChat(chat) }) { Label("Clear Chat", systemImage: "eraser") } + Menu { + Button("As Markdown…") { + ChatExporter.exportWithSavePanel(chat: chat, format: .markdown) + } + Button("As Plain Text…") { + ChatExporter.exportWithSavePanel(chat: chat, format: .plainText) + } + } label: { + Label("Export Chat", systemImage: "square.and.arrow.up") + } Divider() Button(action: { deleteChat(chat) }) { Label("Delete", systemImage: "trash") @@ -119,7 +131,7 @@ struct ChatListRow: View, Equatable { alert.addButton(withTitle: "Delete") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning - alert.beginSheetModal(for: NSApp.keyWindow!) { response in + alert.presentAsSheetOrModal { response in if response == .alertFirstButtonReturn { // Clear selectedChat to prevent accessing deleted item if selectedChat?.objectID == chat.objectID { @@ -148,7 +160,7 @@ struct ChatListRow: View, Equatable { 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 + alert.presentAsSheetOrModal { response in if response == .alertFirstButtonReturn { chat.name = textField.stringValue do { @@ -169,7 +181,7 @@ struct ChatListRow: View, Equatable { 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 { diff --git a/macai/UI/ChatList/ChatListView.swift b/macai/UI/ChatList/ChatListView.swift index c14bfba..30b7b18 100644 --- a/macai/UI/ChatList/ChatListView.swift +++ b/macai/UI/ChatList/ChatListView.swift @@ -87,7 +87,7 @@ struct ChatListView: View { .padding(12) } } - .onChange(of: searchText) { newValue in + .onChange(of: searchText) { _, newValue in debounceTimer?.invalidate() debounceTimer = Timer.scheduledTimer(withTimeInterval: AppConstants.searchDebounceTime, repeats: false) { _ in diff --git a/macai/UI/ContentView.swift b/macai/UI/ContentView.swift index a7b7f08..5d6173f 100644 --- a/macai/UI/ContentView.swift +++ b/macai/UI/ContentView.swift @@ -44,77 +44,15 @@ struct ContentView: View { @State private var lastChatCount = 0 @State private var searchText = "" @State private var isSearchPresented = false + @State private var keyDownMonitor: Any? + @AppStorage("assistantPanelVisible") private var assistantPanelVisible = false + 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) @@ -125,7 +63,7 @@ struct ContentView: View { } } }) - .onChange(of: chats.count) { newCount in + .onChange(of: chats.count) { _, newCount in updateSidebarVisibilityForChatCount(previousCount: lastChatCount, newCount: newCount) lastChatCount = newCount } @@ -179,69 +117,50 @@ struct ContentView: View { } .navigationTitle("Chats") .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 { - Button(action: { - openPreferencesView() - }) { - settingsGearIcon + ToolbarItem(placement: .primaryAction) { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + assistantPanelVisible.toggle() } + }) { + Label("Chat Parameters", systemImage: "slider.horizontal.3") } + .labelStyle(.iconOnly) + .help(assistantPanelVisible ? "Hide chat parameters" : "Show chat parameters") } } - - .onChange(of: scenePhase) { phase in + .searchable( + text: $searchText, + isPresented: $isSearchPresented, + placement: .toolbar, + prompt: "Search" + ) + .onSubmit(of: .search) { + if !searchText.isEmpty { + NotificationCenter.default.post( + name: NSNotification.Name("FindNext"), + object: nil + ) + } + } + .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 +207,13 @@ 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) + } + .focusedSceneValue(\.chatIsSelected, selectedChat != nil) .environmentObject(previewStateManager) } @@ -323,16 +249,23 @@ struct ContentView: View { 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 + if let defaultPersona = service.defaultPersona { + newChat.persona = defaultPersona + newChat.gptModel = defaultPersona.model ?? AppConstants.defaultModel(for: service.type) + newChat.systemMessage = defaultPersona.systemMessage ?? AppConstants.chatGptSystemMessage + } else { + newChat.gptModel = AppConstants.defaultModel(for: service.type) + } } 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 + if let defaultPersona = defaultService.defaultPersona { + newChat.persona = defaultPersona + newChat.gptModel = defaultPersona.model ?? AppConstants.defaultModel(for: defaultService.type) + newChat.systemMessage = defaultPersona.systemMessage ?? AppConstants.chatGptSystemMessage + } else { + newChat.gptModel = AppConstants.defaultModel(for: defaultService.type) + } } do { @@ -397,7 +330,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 +342,102 @@ struct ContentView: View { } } + private var primaryColumn: some View { + ChatListView(selectedChat: $selectedChat, searchText: $searchText) + .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 { + HSplitView { + if let selectedChat { + ChatView( + viewContext: viewContext, + chat: selectedChat, + searchText: $searchText, + isSearchPresented: $isSearchPresented + ) + .frame(minWidth: 400) + .id(openedChatId) + } else { + WelcomeScreen( + chatsCount: chats.count, + apiServiceIsPresent: apiServices.count > 0, + customUrl: apiUrl != AppConstants.apiUrlOpenAIResponses, + openPreferencesView: openPreferencesView, + newChat: newChat + ) + } + + if assistantPanelVisible, let selectedChat { + AssistantInspectorView( + chat: selectedChat, + onServiceChange: { service in + handleServiceChange(selectedChat, service) + } + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + + if previewStateManager.isPreviewVisible { + PreviewPane(stateManager: previewStateManager) + } + } + .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 } + // Add global key monitor for Shift+Enter when search is active + 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 + } + } + } + private func getIndex(for chat: ChatEntity) -> Int { if let index = chats.firstIndex(where: { $0.id == chat.id }) { return index @@ -419,19 +448,26 @@ 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 - } + // The assistant stays selected; only the service it talks to changes. + if let persona = chat.persona, persona.apiService?.objectID != newService.objectID { + persona.apiService = newService + + // Keep the model only when we know the new service offers it; + // otherwise fall back to the service default. + let currentModel = persona.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let knownModels = newService.id.flatMap { ModelsCache.shared.models(for: $0) } ?? [] + if currentModel.isEmpty || !knownModels.contains(currentModel) { + persona.model = AppConstants.defaultModel(for: newService.type) + } + + if !newService.imageGenerationSupported, persona.imageGenerationEnabled { + persona.imageGenerationEnabled = false } } - + chat.apiService = newService - chat.gptModel = newService.model ?? AppConstants.defaultModel(for: newService.type) + let personaModel = chat.persona?.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + chat.gptModel = personaModel.isEmpty ? AppConstants.defaultModel(for: newService.type) : personaModel chat.objectWillChange.send() try? viewContext.save() diff --git a/macai/UI/Preferences/TabAIPersonasView.swift b/macai/UI/Preferences/TabAIPersonasView.swift index fec7ef0..01db2b9 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 @@ -58,10 +57,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( @@ -165,7 +162,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? { @@ -184,6 +180,11 @@ struct TabAIPersonasView: View { 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 +195,34 @@ 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 + + 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 +276,7 @@ struct PersonaDetailView: View { Text("1.0") } - Text(getTemperatureLabel()) + Text(temperature.temperatureDescriptor) .frame(width: 90) } @@ -257,6 +286,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: { @@ -277,7 +414,12 @@ struct PersonaDetailView: View { 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 +427,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) { @@ -306,24 +484,56 @@ struct PersonaDetailView: View { } } - 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() { @@ -332,6 +542,12 @@ struct PersonaDetailView: View { 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() diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift index de47a19..e581186 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 { @@ -28,8 +29,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 +60,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 +75,7 @@ struct APIServiceDetailView: View { .textFieldStyle(RoundedBorderTextFieldStyle()) Button(action: { - viewModel.url = viewModel.defaultApiConfiguration!.url + viewModel.url = viewModel.defaultApiConfiguration?.url ?? AppConstants.apiUrlOpenAIResponses }) { Text("Default") } @@ -91,87 +90,29 @@ 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( lampColor: $lampColor, gptToken: viewModel.apiKey, - gptModel: viewModel.model, + gptModel: viewModel.testModel, apiUrl: viewModel.url, apiType: viewModel.type, - imageGenerationSupported: viewModel.imageGenerationSupported + imageGenerationSupported: viewModel.testImageGenerationEnabled ) } } @@ -179,69 +120,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,25 +157,6 @@ struct APIServiceDetailView: View { } } } - - if viewModel.supportsImageGeneration { - Toggle(isOn: $viewModel.imageGenerationSupported) { - HStack { - Text("Image generation supported") - Button(action: { - }) { - Image(systemName: "questionmark.circle") - .foregroundColor(.blue) - } - .buttonStyle(PlainButtonStyle()) - .help( - "Enable when the selected model should expose image generation features in chat." - ) - - Spacer() - } - } - } } .padding(.vertical, 8) @@ -312,13 +171,6 @@ struct APIServiceDetailView: View { } } - 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) - } - HStack { if viewModel.apiService != nil { Button(action: { diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift b/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift index b8eb981..36ac6b1 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift @@ -5,36 +5,22 @@ // Created by Renat Notfullin on 18.09.2024. // -import Combine +import CoreData import SwiftUI @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 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,31 +32,21 @@ 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 { let serviceIDString = serviceID.uuidString @@ -87,98 +63,32 @@ 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) - } - - 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() - } - } - } - - 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 + var testModel: String { + if let persona = defaultAiPersona, + let model = persona.model, + model.isEmpty == false { + return model } + return defaultApiConfiguration?.defaultModel ?? AppConstants.defaultPrimaryModel } - var availableModels: [String] { - if fetchedModels.isEmpty == false { - return fetchedModels.map { $0.id } - } - else { - return defaultApiConfiguration?.models ?? [] - } + var testImageGenerationEnabled: Bool { + let personaEnabled = defaultAiPersona?.imageGenerationEnabled ?? false + let serviceSupports = apiService?.imageGenerationSupported + ?? defaultApiConfiguration?.imageGenerationSupported + ?? false + return personaEnabled && serviceSupports } func saveAPIService() { @@ -186,18 +96,32 @@ 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 let defaultPersona = defaultAiPersona { + if let boundService = defaultPersona.apiService, boundService != serviceToSave { + // The assistant already belongs to another service. Rebinding it here + // would silently switch every chat using it to this service, so clone + // it instead — same policy as the template-add flow. + let clone = clonePersona(defaultPersona) + clone.apiService = serviceToSave + serviceToSave.defaultPersona = clone + defaultAiPersona = clone + } else { + defaultPersona.apiService = serviceToSave + serviceToSave.defaultPersona = defaultPersona + } + } else { + serviceToSave.defaultPersona = nil + } 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() @@ -254,32 +178,37 @@ 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() + private func clonePersona(_ persona: PersonaEntity) -> PersonaEntity { + let clone = PersonaEntity(context: viewContext) + clone.name = persona.name + clone.color = persona.color + clone.systemMessage = persona.systemMessage + clone.temperature = persona.temperature + clone.contextSize = persona.contextSize + clone.useStreamResponse = persona.useStreamResponse + clone.generateChatNames = persona.generateChatNames + clone.imageGenerationEnabled = persona.imageGenerationEnabled + clone.model = persona.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 + return clone } var supportsImageUploads: Bool { @@ -289,17 +218,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..29314e5 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddViewModel.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddViewModel.swift @@ -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 + service.defaultPersona = personaToUse + do { let identifier = service.id?.uuidString ?? UUID().uuidString service.id = UUID(uuidString: identifier) ?? service.id diff --git a/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift b/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift index dd97308..fe7513b 100644 --- a/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift +++ b/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift @@ -31,12 +31,18 @@ struct ButtonTestApiTokenAndModel: View { } 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 let config = APIServiceConfig( name: apiType, - apiUrl: URL(string: apiUrl)!, + apiUrl: url, apiKey: gptToken, model: gptModel ) @@ -56,7 +62,7 @@ struct ButtonTestApiTokenAndModel: View { case .failure(let error): lampColor = .red let errorMessage = - switch error as! APIError { + switch error as? APIError { case .requestFailed(let error): error.localizedDescription case .invalidResponse: "Response is invalid" case .decodingFailed(let message): message @@ -66,6 +72,7 @@ struct ButtonTestApiTokenAndModel: View { case .unknown(let message): message case .noApiService(let message): message case .attachmentNotReady(let message): message + case nil: error.localizedDescription } showErrorAlert(error: errorMessage) } diff --git a/macai/UI/Preferences/TabAPIServicesView.swift b/macai/UI/Preferences/TabAPIServicesView.swift index a781ab9..feee461 100644 --- a/macai/UI/Preferences/TabAPIServicesView.swift +++ b/macai/UI/Preferences/TabAPIServicesView.swift @@ -129,10 +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("API URL: \(service.url?.absoluteString ?? "Not specified")") + Text("Image uploads: \(service.imageUploadsAllowed ? "Allowed" : "Disabled")") + Text("PDF uploads: \(service.pdfUploadsAllowed ? "Allowed" : "Disabled")") Text("Default AI Assistant: \(service.defaultPersona?.name ?? "None")") } } @@ -152,6 +151,28 @@ struct TabAPIServicesView: View { newService.name = (selectedService.name ?? "") + " Copy" newService.addedDate = Date() + if let defaultPersona = selectedService.defaultPersona { + let newPersona = PersonaEntity(context: viewContext) + newPersona.name = (defaultPersona.name ?? "Assistant") + " Copy" + newPersona.color = defaultPersona.color + newPersona.systemMessage = defaultPersona.systemMessage + newPersona.temperature = defaultPersona.temperature + newPersona.contextSize = defaultPersona.contextSize + newPersona.useStreamResponse = defaultPersona.useStreamResponse + newPersona.generateChatNames = defaultPersona.generateChatNames + newPersona.imageGenerationEnabled = defaultPersona.imageGenerationEnabled + newPersona.model = defaultPersona.model + 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 + newPersona.apiService = newService + newService.defaultPersona = newPersona + } + // Generate new UUID and copy the token let newServiceID = UUID() newService.id = newServiceID diff --git a/macai/UI/Preferences/TabGeneralSettingsView.swift b/macai/UI/Preferences/TabGeneralSettingsView.swift index 894e0e6..7368c2e 100644 --- a/macai/UI/Preferences/TabGeneralSettingsView.swift +++ b/macai/UI/Preferences/TabGeneralSettingsView.swift @@ -293,7 +293,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 +520,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/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/APIServiceManager.swift b/macai/Utilities/APIServiceManager.swift index 86d3707..1764df2 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,71 @@ 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 + ) + + 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..5ad606c --- /dev/null +++ b/macai/Utilities/ChatExporter.swift @@ -0,0 +1,312 @@ +// +// ChatExporter.swift +// macai +// +// Created by Renat Notfullin on 07.07.2026. +// + +import AppKit +import CoreData +import Foundation +import UniformTypeIdentifiers + +enum ChatExportFormat: String, CaseIterable { + case markdown + case plainText + + var displayName: String { + switch self { + case .markdown: return "Markdown" + case .plainText: return "Plain Text" + } + } + + var fileExtension: String { + switch self { + case .markdown: return "md" + case .plainText: return "txt" + } + } + + var contentType: UTType { + switch self { + case .markdown: return UTType(filenameExtension: "md") ?? .plainText + case .plainText: return .plainText + } + } +} + +/// Renders a chat transcript to Markdown or plain text for export. +struct ChatExporter { + struct ExportMessage { + let isUser: Bool + let timestamp: Date + let body: String + } + + struct ExportImage { + let data: Data + let mimeType: String + } + + static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + // MARK: - Rendering + + static func render( + chatName: String, + systemMessage: String?, + messages: [ExportMessage], + format: ChatExportFormat, + fileNameResolver: (UUID) -> String? = { _ in nil }, + imageResolver: (UUID) -> ExportImage? = { _ in nil } + ) -> String { + let title = chatName.trimmingCharacters(in: .whitespacesAndNewlines) + var sections: [String] = [] + + switch format { + case .markdown: + var headerParts: [String] = [] + if !title.isEmpty { + headerParts.append("# \(title)") + } + if let systemMessage = normalized(systemMessage), !systemMessage.isEmpty { + let quoted = systemMessage + .components(separatedBy: "\n") + .map { "> \($0)" } + .joined(separator: "\n") + headerParts.append("**System prompt:**\n\n\(quoted)") + } + if !headerParts.isEmpty { + sections.append(headerParts.joined(separator: "\n\n")) + } + for message in messages { + guard + let body = exportableBody( + message.body, + format: format, + fileNameResolver: fileNameResolver, + imageResolver: imageResolver + ) + else { continue } + let role = message.isUser ? "User" : "Assistant" + let timestamp = timestampFormatter.string(from: message.timestamp) + sections.append("#### \(role) — \(timestamp)\n\n\(body)") + } + return sections.joined(separator: "\n\n---\n\n") + "\n" + + case .plainText: + if !title.isEmpty { + sections.append(title + "\n" + String(repeating: "=", count: min(title.count, 60))) + } + if let systemMessage = normalized(systemMessage), !systemMessage.isEmpty { + sections.append("System prompt:\n\(systemMessage)") + } + for message in messages { + guard let body = exportableBody(message.body, format: format, fileNameResolver: fileNameResolver) + else { continue } + let role = message.isUser ? "User" : "Assistant" + let timestamp = timestampFormatter.string(from: message.timestamp) + sections.append("\(role) — \(timestamp):\n\(body)") + } + return sections.joined(separator: "\n\n" + String(repeating: "-", count: 40) + "\n\n") + "\n" + } + } + + /// Normalizes a raw message body for export: strips reasoning blocks and + /// replaces attachment placeholders with readable markers. + /// Returns nil if nothing remains to export. + static func exportableBody( + _ body: String, + format: ChatExportFormat, + fileNameResolver: (UUID) -> String? = { _ in nil }, + imageResolver: (UUID) -> ExportImage? = { _ in nil } + ) -> String? { + var result = removingThinkingBlocks(from: body) + + result = replacingPlaceholders(in: result, tag: "image-uuid") { uuid in + if format == .markdown, let uuid, let image = imageResolver(uuid) { + let base64 = image.data.base64EncodedString() + return "![Attached image](data:\(image.mimeType);base64,\(base64))" + } + return format == .markdown ? "*[Attached image]*" : "[Attached image]" + } + result = replacingPlaceholders(in: result, tag: "file-uuid") { uuid in + let name = uuid.flatMap(fileNameResolver) + let label = name.map { "Attached file: \($0)" } ?? "Attached file" + return format == .markdown ? "*[\(label)]*" : "[\(label)]" + } + + let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Removes `…` reasoning blocks. An unterminated block is + /// removed to the end of the string (streaming may be cut off mid-thought). + static func removingThinkingBlocks(from text: String) -> String { + guard text.contains("") else { return text } + var result = text + if let regex = try? NSRegularExpression(pattern: ".*?", options: [.dotMatchesLineSeparators]) { + result = regex.stringByReplacingMatches( + in: result, + options: [], + range: NSRange(result.startIndex..., in: result), + withTemplate: "" + ) + } + if let openRange = result.range(of: "") { + result = String(result[.. String + ) -> String { + guard text.contains("<\(tag)>") else { return text } + guard let regex = try? NSRegularExpression(pattern: "<\(tag)>(.*?)", options: []) else { return text } + + var result = "" + var currentIndex = text.startIndex + let nsText = text as NSString + let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length)) + for match in matches { + guard let matchRange = Range(match.range, in: text) else { continue } + result += text[currentIndex.. 1 + ? nsText.substring(with: match.range(at: 1)) + : "" + result += replacement(UUID(uuidString: uuidString)) + currentIndex = matchRange.upperBound + } + result += text[currentIndex...] + return result + } + + private static func normalized(_ text: String?) -> String? { + text?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Core Data integration + + static func export(chat: ChatEntity, format: ChatExportFormat) -> String { + let messages = chat.messagesArray.map { + ExportMessage(isUser: $0.own, timestamp: $0.timestamp, body: $0.body) + } + let context = chat.managedObjectContext + return render( + chatName: chat.name, + systemMessage: chat.systemMessage, + 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 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)" + } + + 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) { + let panel = NSSavePanel() + panel.allowedContentTypes = [format.contentType] + panel.canCreateDirectories = true + panel.isExtensionHidden = false + panel.nameFieldStringValue = suggestedFileName(for: chat, format: format) + panel.title = "Export Chat" + + 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 chatID = chat.objectID + guard !chatID.isTemporaryID, + let coordinator = chat.managedObjectContext?.persistentStoreCoordinator else { + writeExport(export(chat: chat, format: format), to: url) + return + } + + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.persistentStoreCoordinator = coordinator + context.perform { + guard let backgroundChat = try? context.existingObject(with: chatID) as? ChatEntity else { return } + let content = export(chat: backgroundChat, 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..fc7fe71 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,12 @@ class DatabasePatcher { static func applyPatches(context: NSManagedObjectContext, persistence: PersistenceController) { addDefaultPersonasIfNeeded(context: context) patchMissingIDs(context: context, persistence: persistence) + + if persistence.getMetadata(forKey: AppConstants.assistantSettingsMigrationCompletedKey) as? Bool != true { + if migrateAssistantSettingsToPersonas(context: context) { + persistence.setMetadata(value: true, forKey: AppConstants.assistantSettingsMigrationCompletedKey) + } + } if persistence.getMetadata(forKey: AppConstants.personaOrderingPatchCompletedKey) as? Bool != true { if patchPersonaOrdering(context: context) { @@ -114,7 +121,7 @@ class DatabasePatcher { 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 +159,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 +224,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 +244,185 @@ 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 + } + + 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 + } + + var maxOrder = personas.map { $0.order }.max() ?? 0 + + for persona in personas { + if persona.apiService == nil { + if let candidates = personaServiceCandidates[persona.objectID], candidates.count == 1, + let serviceID = candidates.first, + let service = services.first(where: { $0.objectID == serviceID }) { + persona.apiService = service + didChange = true + } + } + applyDefaults(to: persona, service: persona.apiService) + } + + for service in services { + let hasPersona = personas.contains { $0.apiService?.objectID == service.objectID } + if hasPersona == false { + maxOrder += 1 + + let defaultPersona: PersonaEntity + if let existingDefault = service.defaultPersona { + if existingDefault.apiService == nil || existingDefault.apiService == service { + existingDefault.apiService = service + applyDefaults(to: existingDefault, service: service) + defaultPersona = existingDefault + didChange = true + } else { + let clone = clonePersona(existingDefault, order: maxOrder) + clone.apiService = service + applyDefaults(to: clone, service: service) + defaultPersona = clone + didChange = true + } + } else { + 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 + } + + if service.defaultPersona == nil { + service.defaultPersona = defaultPersona + didChange = true + } + } + } + + for chat in chats { + if chat.persona == nil, let service = chat.apiService { + if let defaultPersona = service.defaultPersona { + chat.persona = defaultPersona + if chat.systemMessage.isEmpty { + chat.systemMessage = defaultPersona.systemMessage ?? AppConstants.chatGptSystemMessage + } + didChange = true + } + } + + if let persona = chat.persona, let personaService = persona.apiService { + if chat.apiService?.objectID != personaService.objectID { + chat.apiService = personaService + 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) + 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 +939,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"), @@ -774,6 +966,14 @@ class DatabasePatcher { if let defaultPersona = defaultPersonas.first { print("Found default assistant: \(defaultPersona.name ?? "")") apiService.defaultPersona = defaultPersona + if defaultPersona.apiService == nil { + defaultPersona.apiService = apiService + } + 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 +993,12 @@ class DatabasePatcher { for chat in existingChats { chat.apiService = apiService - chat.gptModel = apiService.model ?? AppConstants.defaultModel(for: apiService.type) + if let defaultPersona = apiService.defaultPersona { + 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..d731cf3 100644 --- a/macai/Utilities/MessageManager.swift +++ b/macai/Utilities/MessageManager.swift @@ -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( @@ -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 { @@ -222,7 +222,11 @@ class MessageManager: ObservableObject { } func generateChatNameIfNeeded(chat: ChatEntity, force: Bool = false) { - guard force || chat.name == "", !chat.messagesArray.isEmpty else { + // `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 @@ -243,7 +247,7 @@ class MessageManager: ObservableObject { switch result { case .success(let messageBody): - let chatName = self.sanitizeChatName(messageBody) + let chatName = MessageManager.sanitizeChatName(messageBody) chat.name = chatName self.viewContext.saveWithRetry(attempts: 3) case .failure(let error): @@ -252,8 +256,8 @@ class MessageManager: ObservableObject { } } - 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 +339,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,7 +351,8 @@ 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, 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/macaiApp.swift b/macai/macaiApp.swift index 0be668a..76388d2 100644 --- a/macai/macaiApp.swift +++ b/macai/macaiApp.swift @@ -388,14 +388,42 @@ 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) - } - .onChange(of: scenePhase) { phase in + .onChange(of: scenePhase) { _, phase in if phase == .active { if UserDefaults.standard.bool(forKey: "autoCheckForUpdates") { updaterController.updater.checkForUpdatesInBackground() @@ -439,6 +467,10 @@ struct macaiApp: App { ) } .keyboardShortcut(".", modifiers: .command) + + Divider() + + ExportChatMenu(post: postExportChatNotification) } CommandGroup(replacing: .newItem) { @@ -475,3 +507,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..59003b4 --- /dev/null +++ b/macaiTests/Utilities/ChatEntityTests.swift @@ -0,0 +1,163 @@ +// +// 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) + } +} diff --git a/macaiTests/Utilities/ChatExporterTests.swift b/macaiTests/Utilities/ChatExporterTests.swift new file mode 100644 index 0000000..0268796 --- /dev/null +++ b/macaiTests/Utilities/ChatExporterTests.swift @@ -0,0 +1,224 @@ +// +// ChatExporterTests.swift +// macaiTests +// +// Created by Renat Notfullin on 07.07.2026. +// + +import XCTest + +@testable import macai + +class ChatExporterTests: XCTestCase { + + private func message(_ body: String, isUser: Bool, timestamp: Date = Date(timeIntervalSince1970: 0)) + -> ChatExporter.ExportMessage + { + ChatExporter.ExportMessage(isUser: isUser, timestamp: timestamp, body: body) + } + + // MARK: - Thinking block removal + + func testRemovingThinkingBlocksStripsClosedBlock() { + let input = "internal reasoning\nThe answer is 42." + let result = ChatExporter.removingThinkingBlocks(from: input) + XCTAssertEqual(result.trimmingCharacters(in: .whitespacesAndNewlines), "The answer is 42.") + } + + func testRemovingThinkingBlocksStripsMultilineBlock() { + let input = "line one\nline two\nline threeAnswer" + let result = ChatExporter.removingThinkingBlocks(from: input) + XCTAssertEqual(result, "Answer") + } + + func testRemovingThinkingBlocksStripsUnterminatedBlock() { + let input = "Answer first.\nstreaming was cut off here" + let result = ChatExporter.removingThinkingBlocks(from: input) + XCTAssertEqual(result.trimmingCharacters(in: .whitespacesAndNewlines), "Answer first.") + } + + func testRemovingThinkingBlocksLeavesPlainTextUntouched() { + let input = "Nothing to strip here." + XCTAssertEqual(ChatExporter.removingThinkingBlocks(from: input), input) + } + + // MARK: - Attachment placeholders + + func testExportableBodyReplacesImagePlaceholder() { + let uuid = UUID().uuidString + let input = "Look at this:\n\(uuid)\nNice, right?" + + let markdown = ChatExporter.exportableBody(input, format: .markdown) + XCTAssertEqual(markdown, "Look at this:\n*[Attached image]*\nNice, right?") + + let plain = ChatExporter.exportableBody(input, format: .plainText) + XCTAssertEqual(plain, "Look at this:\n[Attached image]\nNice, right?") + } + + func testExportableBodyReplacesFilePlaceholderWithResolvedName() { + let uuid = UUID() + let input = "\(uuid.uuidString)\nSummarize this document." + + let result = ChatExporter.exportableBody(input, format: .plainText) { resolvedUUID in + XCTAssertEqual(resolvedUUID, uuid) + return "report.pdf" + } + XCTAssertEqual(result, "[Attached file: report.pdf]\nSummarize this document.") + } + + func testExportableBodyReplacesFilePlaceholderWithoutResolvedName() { + let input = "\(UUID().uuidString) Question" + let result = ChatExporter.exportableBody(input, format: .plainText) { _ in nil } + XCTAssertEqual(result, "[Attached file] Question") + } + + func testExportableBodyEmbedsImageAsDataURIInMarkdown() { + let uuid = UUID() + let imageData = Data([0xFF, 0xD8, 0xFF, 0xE0]) + let input = "Check this out:\n\(uuid.uuidString)" + + let result = ChatExporter.exportableBody(input, format: .markdown, imageResolver: { resolvedUUID in + XCTAssertEqual(resolvedUUID, uuid) + return ChatExporter.ExportImage(data: imageData, mimeType: "image/jpeg") + }) + + let expectedURI = "data:image/jpeg;base64,\(imageData.base64EncodedString())" + XCTAssertEqual(result, "Check this out:\n![Attached image](\(expectedURI))") + } + + func testExportableBodyDoesNotEmbedImageInPlainText() { + let input = "\(UUID().uuidString) What is this?" + let result = ChatExporter.exportableBody(input, format: .plainText, imageResolver: { _ in + ChatExporter.ExportImage(data: Data([0x01]), mimeType: "image/png") + }) + XCTAssertEqual(result, "[Attached image] What is this?") + } + + func testExportableBodyFallsBackToMarkerWhenImageMissing() { + let input = "\(UUID().uuidString)" + let result = ChatExporter.exportableBody(input, format: .markdown, imageResolver: { _ in nil }) + XCTAssertEqual(result, "*[Attached image]*") + } + + func testExportableBodyHandlesMultiplePlaceholdersInOneLine() { + let input = "\(UUID().uuidString)\(UUID().uuidString)Compare" + let result = ChatExporter.exportableBody(input, format: .plainText) + XCTAssertEqual(result, "[Attached image][Attached image]Compare") + } + + func testExportableBodyReturnsNilForEmptyResult() { + XCTAssertNil(ChatExporter.exportableBody("", format: .markdown)) + XCTAssertNil(ChatExporter.exportableBody(" \n ", format: .markdown)) + XCTAssertNil(ChatExporter.exportableBody("only reasoning", format: .markdown)) + } + + func testExportableBodyKeepsMarkdownContentIntact() { + let input = "```swift\nlet x = 1\n```\n**bold** and | a | table |" + XCTAssertEqual(ChatExporter.exportableBody(input, format: .markdown), input) + } + + // MARK: - Full rendering + + func testRenderMarkdownTranscript() { + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let formattedTimestamp = ChatExporter.timestampFormatter.string(from: timestamp) + let result = ChatExporter.render( + chatName: "Test Chat", + systemMessage: "You are a helpful assistant.", + messages: [ + message("Hello!", isUser: true, timestamp: timestamp), + message("Hi, how can I help?", isUser: false, timestamp: timestamp), + ], + format: .markdown + ) + + let expected = """ + # Test Chat + + **System prompt:** + + > You are a helpful assistant. + + --- + + #### User — \(formattedTimestamp) + + Hello! + + --- + + #### Assistant — \(formattedTimestamp) + + Hi, how can I help? + + """ + XCTAssertEqual(result, expected) + } + + func testRenderPlainTextTranscript() { + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let formattedTimestamp = ChatExporter.timestampFormatter.string(from: timestamp) + let result = ChatExporter.render( + chatName: "Test Chat", + systemMessage: nil, + messages: [ + message("Question?", isUser: true, timestamp: timestamp), + message("Answer.", isUser: false, timestamp: timestamp), + ], + format: .plainText + ) + + let separator = String(repeating: "-", count: 40) + let expected = """ + Test Chat + ========= + + \(separator) + + User — \(formattedTimestamp): + Question? + + \(separator) + + Assistant — \(formattedTimestamp): + Answer. + + """ + XCTAssertEqual(result, expected) + } + + func testRenderSkipsEmptyMessages() { + let result = ChatExporter.render( + chatName: "Chat", + systemMessage: nil, + messages: [ + message("reasoning only", isUser: false), + message("Visible", isUser: true), + ], + format: .plainText + ) + XCTAssertFalse(result.contains("reasoning only")) + XCTAssertTrue(result.contains("Visible")) + } + + func testRenderMultilineSystemMessageIsFullyQuotedInMarkdown() { + let result = ChatExporter.render( + chatName: "Chat", + systemMessage: "Line one\nLine two", + messages: [], + format: .markdown + ) + XCTAssertTrue(result.contains("> Line one\n> Line two")) + } + + func testRenderWithEmptyChatNameOmitsTitle() { + let result = ChatExporter.render( + chatName: " ", + systemMessage: nil, + messages: [message("Hi", isUser: true)], + format: .markdown + ) + XCTAssertTrue(result.hasPrefix("#### User")) + XCTAssertTrue(result.contains("Hi")) + } +} diff --git a/macaiTests/Utilities/DatabasePatcherTests.swift b/macaiTests/Utilities/DatabasePatcherTests.swift new file mode 100644 index 0000000..13c1ac2 --- /dev/null +++ b/macaiTests/Utilities/DatabasePatcherTests.swift @@ -0,0 +1,212 @@ +// +// 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 testMigrationKeepsPersonaUnboundWhenUsedWithMultipleServices() throws { + let serviceA = makeService(name: "OpenAI") + let serviceB = makeService(name: "Claude") + let persona = makePersona(name: "Shared") + let chatA = makeChat(persona: persona, service: serviceA) + let chatB = makeChat(persona: persona, service: serviceB) + try context.save() + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + + // The persona is ambiguous and must stay unbound; each chat keeps its own service. + XCTAssertNil(persona.apiService) + XCTAssertEqual(chatA.apiService?.objectID, serviceA.objectID) + XCTAssertEqual(chatB.apiService?.objectID, serviceB.objectID) + XCTAssertEqual(chatA.persona?.objectID, persona.objectID) + XCTAssertEqual(chatB.persona?.objectID, persona.objectID) + } + + func testMigrationCreatesDefaultPersonaForServiceWithoutOne() throws { + let service = makeService(name: "Mistral") + try context.save() + + XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) + + let defaultPersona = try XCTUnwrap(service.defaultPersona) + XCTAssertEqual(defaultPersona.apiService?.objectID, service.objectID) + XCTAssertEqual(defaultPersona.model, "gpt-4o") + } + + 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?.objectID, service.defaultPersona?.objectID) + } + + 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 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/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]))) + } +} From 3108d696896c01e655ba6b0ecbdc42b1e9c2c599 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sat, 11 Jul 2026 20:00:05 +0300 Subject: [PATCH 02/23] Prepare v3.0.0 release --- README.md | 2 +- macai.xcodeproj/project.pbxproj | 69 ++-- .../xcshareddata/swiftpm/Package.resolved | 11 +- macai/Models/Models.swift | 67 +++- .../macaiDataModel 4.xcdatamodel/contents | 3 + .../BottomContainer/PersonaSelectorView.swift | 5 + .../Chat/BubbleView/MessageContentView.swift | 1 - macai/UI/Chat/ChatMessagesView.swift | 246 +++++-------- ...ectorView.swift => ChatSettingsView.swift} | 248 +++++++------ macai/UI/Chat/ChatViewModel.swift | 36 +- macai/UI/Chat/CodeView/CodeView.swift | 1 - macai/UI/ContentView.swift | 331 ++++++++++++++---- .../Preferences/TabGeneralSettingsView.swift | 1 - macai/UI/WelcomeScreen/WelcomeScreen.swift | 11 +- macai/Utilities/MessageManager.swift | 7 +- .../AttributedText/AttributedText.swift | 60 ++++ .../AttributedTextImpl+macOS.swift | 99 ++++++ .../AttributedText/AttributedTextImpl.swift | 14 + .../NSLineBreakMode+TruncationMode.swift | 22 ++ .../AttributedText/TextSizeViewModel.swift | 17 + macai/macaiApp.swift | 65 ++++ macaiTests/Utilities/ChatEntityTests.swift | 111 ++++++ 22 files changed, 989 insertions(+), 438 deletions(-) rename macai/UI/Chat/{AssistantInspectorView.swift => ChatSettingsView.swift} (69%) create mode 100644 macai/Vendor/AttributedText/AttributedText.swift create mode 100644 macai/Vendor/AttributedText/AttributedTextImpl+macOS.swift create mode 100644 macai/Vendor/AttributedText/AttributedTextImpl.swift create mode 100644 macai/Vendor/AttributedText/NSLineBreakMode+TruncationMode.swift create mode 100644 macai/Vendor/AttributedText/TextSizeViewModel.swift diff --git a/README.md b/README.md index 9928d1e..30cc0dc 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ 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. diff --git a/macai.xcodeproj/project.pbxproj b/macai.xcodeproj/project.pbxproj index d38517b..373f534 100644 --- a/macai.xcodeproj/project.pbxproj +++ b/macai.xcodeproj/project.pbxproj @@ -24,7 +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 /* AssistantInspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70CC51F011F74CE497CC83D9 /* AssistantInspectorView.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 */; }; @@ -45,6 +45,11 @@ 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 */; }; @@ -65,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 */; }; @@ -173,7 +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 /* AssistantInspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantInspectorView.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 = ""; }; @@ -193,6 +197,11 @@ 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 = ""; }; @@ -282,7 +291,6 @@ 4A2842492C87014800E5C920 /* KeychainAccess in Frameworks */, 4A471B862C29C56800C66926 /* SwiftMath in Frameworks */, 4A7E1BDB29C910F900E57DC2 /* Sparkle in Frameworks */, - 4A51EC6529C32B0B00973D83 /* AttributedText in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -366,7 +374,7 @@ children = ( 4AC37B622F1255C0006FD196 /* AttachmentPreviewViews.swift */, 4AC37B7D2F14000100D1A2B3 /* ChatDraftManager.swift */, - 70CC51F011F74CE497CC83D9 /* AssistantInspectorView.swift */, + 70CC51F011F74CE497CC83D9 /* ChatSettingsView.swift */, 4A1E16832E16DB4500E5CBA3 /* ChatInputView.swift */, 4A1E16842E16DB4500E5CBA3 /* ChatMessagesView.swift */, 4A1E16822E16DB4500E5CBA3 /* ChatLogicHandler.swift */, @@ -508,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 = ( @@ -515,6 +543,7 @@ 4A399C9C29BC8FF100E98796 /* Assets.xcassets */, 4A399CA029BC8FF100E98796 /* Preview Assets.xcassets */, 4ABDBE6429F86B3900661B2D /* Configuration */, + C4E7B0A12E1A00010000001D /* Vendor */, 4A272A7029F5D9C200F93D0D /* Utilities */, 4A272A6F29F5CE5700F93D0D /* Resources */, 4A272A6A29F5CD5E00F93D0D /* Store */, @@ -617,7 +646,6 @@ name = macai; packageProductDependencies = ( 4A51EC6129C32AF700973D83 /* Highlightr */, - 4A51EC6429C32B0B00973D83 /* AttributedText */, 4A7E1BDA29C910F900E57DC2 /* Sparkle */, 4A471B852C29C56800C66926 /* SwiftMath */, 4A2842482C87014800E5C920 /* KeychainAccess */, @@ -696,7 +724,6 @@ mainGroup = 4A399C8C29BC8FEF00E98796; packageReferences = ( 4A51EC6029C32AF600973D83 /* XCRemoteSwiftPackageReference "Highlightr" */, - 4A51EC6329C32B0B00973D83 /* XCRemoteSwiftPackageReference "AttributedText" */, 4A7E1BD929C910F800E57DC2 /* XCRemoteSwiftPackageReference "Sparkle" */, 4A471B842C29C56800C66926 /* XCRemoteSwiftPackageReference "SwiftMath" */, 4A2842472C87014800E5C920 /* XCRemoteSwiftPackageReference "KeychainAccess" */, @@ -772,6 +799,11 @@ 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 */, @@ -833,7 +865,7 @@ 4A1E16852E16DB4500E5CBA3 /* ChatInputView.swift in Sources */, 4A1E16862E16DB4500E5CBA3 /* ChatMessagesView.swift in Sources */, 4A1E16872E16DB4600E5CBA3 /* ChatLogicHandler.swift in Sources */, - 9BC46E70111B408B9B189BE1 /* AssistantInspectorView.swift in Sources */, + 9BC46E70111B408B9B189BE1 /* ChatSettingsView.swift in Sources */, 4AFEE7252C472FB1006F99FB /* OllamaHandler.swift in Sources */, 4AE8691B2D5696A300E3B3AC /* GeminiHandler.swift in Sources */, ); @@ -1028,7 +1060,7 @@ 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; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 3.0.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = notfullin.com.macai; @@ -1071,7 +1103,7 @@ 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; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 3.0.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = notfullin.com.macai; @@ -1095,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)"; @@ -1118,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)"; @@ -1241,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"; @@ -1275,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" */; 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/Models/Models.swift b/macai/Models/Models.swift index ff133b0..bb35365 100644 --- a/macai/Models/Models.swift +++ b/macai/Models/Models.swift @@ -15,6 +15,14 @@ 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 top_p: Double @NSManaged public var behavior: String? @NSManaged public var draftMessage: String? @@ -302,33 +310,40 @@ 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? { - persona?.apiService ?? apiService + apiService ?? persona?.apiService } var effectiveModel: String { - let service = effectiveApiService - let defaultModel = AppConstants.defaultModel(for: service?.type) let chatModel = gptModel.trimmingCharacters(in: .whitespacesAndNewlines) + if chatModel.isEmpty == false { + return chatModel + } let personaModel = persona?.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let personaServiceID = persona?.apiService?.objectID - let serviceID = service?.objectID - let personaMatchesService = personaServiceID == nil ? serviceID == nil : personaServiceID == serviceID - - if personaMatchesService, personaModel.isEmpty == false { + if personaModel.isEmpty == false { return personaModel } - if chatModel.isEmpty == false { - return chatModel + return AppConstants.defaultModel(for: effectiveApiService?.type) + } + + var effectiveTemperature: Float { + if temperatureOverridden { + return Float(min(max(temperature, 0), 1)) } - return defaultModel + return persona?.temperature ?? AppConstants.defaultTemperatureForChat } var effectiveContextSize: Int { - let raw = Int(persona?.contextSize ?? 0) - if raw > 0 { - return raw + if contextSize > 0 { + return Int(contextSize) + } + let personaSize = Int(persona?.contextSize ?? 0) + if personaSize > 0 { + return personaSize } return Int(AppConstants.chatGptContextSize) } @@ -342,13 +357,35 @@ extension ChatEntity { } var imageGenerationEnabled: Bool { - persona?.imageGenerationEnabled ?? false + 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 + + 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 } diff --git a/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents b/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents index fa67af3..37e5696 100644 --- a/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents +++ b/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents @@ -23,8 +23,11 @@ + + + diff --git a/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift b/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift index 7dd7278..e7da17b 100644 --- a/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift +++ b/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift @@ -76,6 +76,11 @@ struct PersonaSelectorView: View { private func updatePersonaAndSystemMessage(to persona: PersonaEntity?) { chat.persona = persona 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 if let service = persona?.apiService { chat.apiService = service let personaModel = persona?.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 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/ChatMessagesView.swift b/macai/UI/Chat/ChatMessagesView.swift index ceac5fc..b128d4c 100644 --- a/macai/UI/Chat/ChatMessagesView.swift +++ b/macai/UI/Chat/ChatMessagesView.swift @@ -19,163 +19,42 @@ struct ChatMessagesView: View { @Binding var searchText: String let reasoningDurations: [NSManagedObjectID: TimeInterval] let activeReasoningMessageID: NSManagedObjectID? - @State private var scrollDebounceWorkItem: DispatchWorkItem? - @State private var initialLoadSettleWorkItem: DispatchWorkItem? - @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(spacing: 0) { - 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) - - // Invisible sentinel: the true bottom of the scrollable content. - // It sits below the list padding, so anchoring 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) - } - .background( - GeometryReader { geometry in - Color.clear.preference(key: ContentHeightPreferenceKey.self, value: geometry.size.height) - } - ) - .onPreferenceChange(ContentHeightPreferenceKey.self) { _ in - // Async rendering (code highlighting, images) keeps changing the - // content height after the first layout pass. While the chat is - // still settling after being opened, re-pin to the bottom on - // every height change so we never stop short of the last message. - guard isInitialLoad, !userIsScrolling else { return } - // Pin on the next runloop turn so the scroll target is computed - // from the freshly committed layout, not mid-update metrics. - DispatchQueue.main.async { - guard isInitialLoad, !userIsScrolling else { return } - scrollView.scrollTo(Self.bottomAnchorID, anchor: .bottom) - } - scheduleInitialLoadSettle() - } - .onAppear { - isInitialLoad = true - scheduleInitialLoadSettle() - } - .onChange(of: userIsScrolling) { _, isScrolling in - // The user took over; stop forcing the view to the bottom. - if isScrolling { - endInitialLoad() - } - } - .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 { - withAnimation(.easeOut(duration: 1)) { - scrollView.scrollTo(Self.bottomAnchorID, anchor: .bottom) - } - } - - scrollDebounceWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) - } + messagesList + .padding(24) + + // 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 + // 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(Self.bottomAnchorID, anchor: .bottom) } } } - .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 { - withAnimation(.easeOut(duration: 0.5)) { - scrollView.scrollTo(Self.bottomAnchorID, anchor: .bottom) - } - } - } - } - } .onChange(of: chatViewModel.currentSearchOccurrence) { _, newOccurrence in if let occurrence = newOccurrence { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { @@ -189,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( @@ -209,28 +101,68 @@ struct ChatMessagesView: View { } } - /// Ends the "initial load" phase once the content height has been stable - /// for a short quiet period, i.e. async rendering has finished. - private func scheduleInitialLoadSettle() { - initialLoadSettleWorkItem?.cancel() - let workItem = DispatchWorkItem { - isInitialLoad = false - } - initialLoadSettleWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 1.25, execute: workItem) - } + 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) + } + } - private func endInitialLoad() { - initialLoadSettleWorkItem?.cancel() - initialLoadSettleWorkItem = nil - isInitialLoad = false - } -} + if shouldShowWaitingBubble { + let bubbleContent = ChatBubbleContent( + message: "", + own: false, + waitingForResponse: true, + errorMessage: nil, + systemMessage: false, + isStreaming: isStreaming, + isLatestMessage: false, + reasoningDuration: nil, + isActiveReasoning: false + ) -private struct ContentHeightPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 + 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 + ) - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = max(value, nextValue()) + ChatBubbleView(content: bubbleContent, searchText: $searchText) + .id(-2) + } + } } } diff --git a/macai/UI/Chat/AssistantInspectorView.swift b/macai/UI/Chat/ChatSettingsView.swift similarity index 69% rename from macai/UI/Chat/AssistantInspectorView.swift rename to macai/UI/Chat/ChatSettingsView.swift index 3e10aa5..eaead82 100644 --- a/macai/UI/Chat/AssistantInspectorView.swift +++ b/macai/UI/Chat/ChatSettingsView.swift @@ -1,5 +1,5 @@ // -// AssistantInspectorView.swift +// ChatSettingsView.swift // macai // // Created by Codex on 16.01.2026. @@ -9,7 +9,10 @@ import AppKit import CoreData import SwiftUI -struct AssistantInspectorView: View { +/// 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 @@ -36,7 +39,7 @@ struct AssistantInspectorView: View { return AppConstants.defaultApiConfigurations[type]?.models ?? [] } - private var supportsImageGeneration: Bool { + private var serviceSupportsImageGeneration: Bool { chat.effectiveApiService?.imageGenerationSupported ?? false } @@ -46,27 +49,29 @@ struct AssistantInspectorView: View { @ViewBuilder private var content: some View { - VStack(alignment: .leading, spacing: 12) { - headerSection - - if let persona = chat.persona { - personaSettings(persona) - } else { - emptyStateSection + 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) } - .padding(.horizontal, 16) - .padding(.bottom, 16) - .padding(.top, 36) - .frame(minWidth: 260, idealWidth: 320, maxWidth: 420) - .background(Color(NSColor.windowBackgroundColor)) .onAppear { syncServiceSelection() - // Only persist when the stored value is actually stale. - if supportsImageGeneration == false, chat.persona?.imageGenerationEnabled == true { - chat.persona?.imageGenerationEnabled = false - viewContext.saveWithRetry(attempts: 1) - } refreshModels() } .onDisappear { @@ -83,47 +88,22 @@ struct AssistantInspectorView: View { } } - private func syncServiceSelection() { - let currentID = chat.effectiveApiService?.objectID - if let currentID, - apiServices.contains(where: { $0.objectID == currentID }) { - selectedServiceID = currentID - } else { - selectedServiceID = nil - } - } - private var headerSection: some View { HStack { - Text("Assistant Settings") + Text("Chat Settings") .font(.headline) Spacer() } } - @ViewBuilder - private func personaSettings(_ persona: PersonaEntity) -> some View { - personaHeaderSection(persona) - Divider() - apiServiceSection - Divider() - modelSection(persona) - Divider() - temperatureSection(persona) - Divider() - contextSizeSection(persona) - Divider() - toolsSection(persona) - Spacer() - } - - private func personaHeaderSection(_ persona: PersonaEntity) -> some View { + private var assistantSection: some View { VStack(alignment: .leading, spacing: 6) { - Text(persona.name ?? "Assistant") + Text(chat.persona?.name ?? "No Assistant") .font(.title3) - Text(chat.effectiveApiService?.name ?? "No API Service") - .font(.subheadline) + 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) } } @@ -152,7 +132,7 @@ struct AssistantInspectorView: View { } @ViewBuilder - private func modelSection(_ persona: PersonaEntity) -> some View { + private var modelSection: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { Text("Model") @@ -168,7 +148,7 @@ struct AssistantInspectorView: View { } if availableModels.isEmpty { - TextField("Enter model name", text: customModelBinding(for: persona)) + TextField("Enter model name", text: customModelBinding) .textFieldStyle(RoundedBorderTextFieldStyle()) } else { Picker("Model", selection: $selectedModel) { @@ -183,12 +163,12 @@ struct AssistantInspectorView: View { if newValue == "custom" { isCustomModel = true if customModel.isEmpty { - customModel = persona.model ?? "" + customModel = chat.effectiveModel } } else { isCustomModel = false customModel = newValue - updateModel(persona, value: newValue) + updateModel(newValue) } } @@ -196,28 +176,28 @@ struct AssistantInspectorView: View { TextField("Enter custom model name", text: $customModel) .textFieldStyle(RoundedBorderTextFieldStyle()) .onChange(of: customModel) { _, newValue in - updateModel(persona, value: newValue) + updateModel(newValue) } } } } } - private func temperatureSection(_ persona: PersonaEntity) -> some View { + private var temperatureSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Temperature") .font(.subheadline) .foregroundStyle(.secondary) Slider( - value: temperatureBinding(for: persona), + value: temperatureBinding, in: 0...1, step: 0.1 ) HStack { Text("0.0") Spacer() - Text("\(String(format: "%.1f", persona.temperature)) · \(persona.temperature.temperatureDescriptor)") + Text("\(String(format: "%.1f", chat.effectiveTemperature)) · \(chat.effectiveTemperature.temperatureDescriptor)") Spacer() Text("1.0") } @@ -226,37 +206,33 @@ struct AssistantInspectorView: View { } } - private func contextSizeSection(_ persona: PersonaEntity) -> some View { - let effectiveContextSize = persona.contextSize == 0 - ? Int16(AppConstants.chatGptContextSize) - : persona.contextSize - - return VStack(alignment: .leading, spacing: 8) { + private var contextSizeSection: some View { + VStack(alignment: .leading, spacing: 8) { Text("Context size") .font(.subheadline) .foregroundStyle(.secondary) Slider( - value: contextSizeBinding(for: persona), + value: contextSizeBinding, in: 5...100, step: 5 ) - Text("\(Int(effectiveContextSize)) messages") + Text("\(chat.effectiveContextSize) messages") .font(.caption) .foregroundStyle(.secondary) } } - private func toolsSection(_ persona: PersonaEntity) -> some View { + private var toolsSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Tools") .font(.subheadline) .foregroundStyle(.secondary) - Toggle("Image generation", isOn: imageGenerationBinding(for: persona)) - .disabled(!supportsImageGeneration) - .help(supportsImageGeneration ? "" : "This API service does not support image generation.") + 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) @@ -267,6 +243,18 @@ struct AssistantInspectorView: View { } } + @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") @@ -278,82 +266,100 @@ struct AssistantInspectorView: View { .accessibilityLabel("\(title): \(isAvailable ? "available" : "not available with this API service")") } - private var emptyStateSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Select an assistant to edit its settings.") - .foregroundStyle(.secondary) - Spacer() - } - } - - /// 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) - } + // MARK: - Bindings (all write to the chat, never to the assistant) - private func flushPendingSave() { - pendingSaveWorkItem?.cancel() - pendingSaveWorkItem = nil - if viewContext.hasChanges { - viewContext.saveWithRetry(attempts: 1) - } - } - - private func temperatureBinding(for persona: PersonaEntity) -> Binding { + private var temperatureBinding: Binding { Binding( - get: { Double(persona.temperature) }, + get: { Double(chat.effectiveTemperature) }, set: { newValue in - persona.temperature = Float(newValue) + chat.temperature = newValue + chat.temperatureOverridden = true scheduleDebouncedSave() } ) } - private func contextSizeBinding(for persona: PersonaEntity) -> Binding { + private var contextSizeBinding: Binding { Binding( - get: { Double(persona.contextSize == 0 ? Int16(AppConstants.chatGptContextSize) : persona.contextSize) }, + get: { Double(chat.effectiveContextSize) }, set: { newValue in - persona.contextSize = Int16(newValue) + chat.contextSize = Int16(newValue) scheduleDebouncedSave() } ) } - private func imageGenerationBinding(for persona: PersonaEntity) -> Binding { + private var imageGenerationBinding: Binding { Binding( - get: { persona.imageGenerationEnabled }, + get: { chat.imageGenerationEnabled }, set: { newValue in - persona.imageGenerationEnabled = newValue + chat.imageGenerationMode = newValue ? 1 : 2 viewContext.saveWithRetry(attempts: 1) } ) } - private func customModelBinding(for persona: PersonaEntity) -> Binding { + private var customModelBinding: Binding { Binding( get: { - customModel.isEmpty ? (persona.model ?? "") : customModel + customModel.isEmpty ? chat.effectiveModel : customModel }, set: { newValue in customModel = newValue - updateModel(persona, value: newValue) + updateModel(newValue) } ) } - private func updateModel(_ persona: PersonaEntity, value: String) { + // MARK: - Actions + + private func updateModel(_ value: String) { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard persona.model != trimmed else { return } - persona.model = trimmed + guard trimmed.isEmpty == false, chat.gptModel != trimmed else { return } + chat.gptModel = trimmed 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 = [] @@ -397,9 +403,7 @@ struct AssistantInspectorView: View { } private func syncModelSelection() { - guard let persona = chat.persona else { return } - let currentModel = persona.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let defaultModel = AppConstants.defaultModel(for: chat.effectiveApiService?.type) + let currentModel = chat.effectiveModel if availableModels.isEmpty { selectedModel = currentModel @@ -408,22 +412,14 @@ struct AssistantInspectorView: View { return } - if currentModel.isEmpty == false, availableModels.contains(currentModel) { + if availableModels.contains(currentModel) { selectedModel = currentModel isCustomModel = false customModel = currentModel - } else if currentModel.isEmpty == false { + } else { selectedModel = "custom" isCustomModel = true customModel = currentModel - } else if availableModels.contains(defaultModel) { - selectedModel = defaultModel - isCustomModel = false - customModel = defaultModel - } else if let firstModel = availableModels.first { - selectedModel = firstModel - isCustomModel = false - customModel = firstModel } } } @@ -431,5 +427,5 @@ struct AssistantInspectorView: View { #Preview { let context = PersistenceController.preview.container.viewContext let chat = ChatEntity(context: context) - return AssistantInspectorView(chat: chat, onServiceChange: { _ in }) + return ChatSettingsView(chat: chat, onServiceChange: { _ in }) } diff --git a/macai/UI/Chat/ChatViewModel.swift b/macai/UI/Chat/ChatViewModel.swift index b67f9be..d89f090 100644 --- a/macai/UI/Chat/ChatViewModel.swift +++ b/macai/UI/Chat/ChatViewModel.swift @@ -66,6 +66,8 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg private var isApplyingServiceChanges = false 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 @@ -76,6 +78,8 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg 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) @@ -220,14 +224,21 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg 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 && relationshipChanged) else { + guard personaObjectChanged || serviceObjectChanged || (chatObjectChanged && chatConfigChanged) else { return } self.lastObservedPersonaID = personaID self.lastObservedServiceID = serviceID - self.applyAPIServiceChanges(forceRecreate: personaObjectChanged || serviceObjectChanged || relationshipChanged) + self.lastObservedModel = self.chat.gptModel + self.lastObservedImageGenerationMode = self.chat.imageGenerationMode + self.applyAPIServiceChanges(forceRecreate: personaObjectChanged || serviceObjectChanged || chatConfigChanged) } } @@ -245,22 +256,19 @@ 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 resolvedService = chat.persona?.apiService ?? chat.apiService - if let resolvedService, chat.apiService != resolvedService { - chat.apiService = resolvedService + if chat.apiService == nil, let personaService = chat.persona?.apiService { + chat.apiService = personaService didChange = true } - let fallbackModel = chat.gptModel.isEmpty - ? AppConstants.defaultModel(for: resolvedService?.type) - : chat.gptModel - let resolvedModel = chat.persona?.model?.isEmpty == false - ? (chat.persona?.model ?? fallbackModel) - : fallbackModel - - if chat.gptModel != resolvedModel { - chat.gptModel = resolvedModel + 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 } diff --git a/macai/UI/Chat/CodeView/CodeView.swift b/macai/UI/Chat/CodeView/CodeView.swift index 8f12dcd..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 diff --git a/macai/UI/ContentView.swift b/macai/UI/ContentView.swift index 5d6173f..f6c6dba 100644 --- a/macai/UI/ContentView.swift +++ b/macai/UI/ContentView.swift @@ -46,6 +46,10 @@ struct ContentView: View { @State private var isSearchPresented = 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 { @@ -68,6 +72,25 @@ struct ContentView: View { 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, @@ -128,30 +151,31 @@ struct ContentView: View { } } + if !isSearchPresented { + ToolbarItem(placement: .primaryAction) { + Button(action: { + isSearchPresented = true + }) { + Label("Find in Chat", systemImage: "magnifyingglass") + } + .labelStyle(.iconOnly) + .help("Find in Chat (⌘F)") + } + } + + // 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: { - withAnimation(.easeInOut(duration: 0.2)) { - assistantPanelVisible.toggle() - } + assistantPanelVisible.toggle() }) { - Label("Chat Parameters", systemImage: "slider.horizontal.3") + // 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 parameters" : "Show chat parameters") - } - } - .searchable( - text: $searchText, - isPresented: $isSearchPresented, - placement: .toolbar, - prompt: "Search" - ) - .onSubmit(of: .search) { - if !searchText.isEmpty { - NotificationCenter.default.post( - name: NSNotification.Name("FindNext"), - object: nil - ) + .help(assistantPanelVisible ? "Hide chat settings" : "Show chat settings") } } .onChange(of: scenePhase) { _, phase in @@ -314,12 +338,7 @@ struct ContentView: View { } func openPreferencesView() { - if #available(macOS 13.0, *) { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - } - else { - NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) - } + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } func clearSelectedChat() { @@ -367,38 +386,12 @@ struct ContentView: View { @ViewBuilder private var detailColumn: some View { - HSplitView { - if let selectedChat { - ChatView( - viewContext: viewContext, - chat: selectedChat, - searchText: $searchText, - isSearchPresented: $isSearchPresented - ) - .frame(minWidth: 400) - .id(openedChatId) - } else { - WelcomeScreen( - chatsCount: chats.count, - apiServiceIsPresent: apiServices.count > 0, - customUrl: apiUrl != AppConstants.apiUrlOpenAIResponses, - openPreferencesView: openPreferencesView, - newChat: newChat - ) - } + HStack(spacing: 0) { + chatContent - if assistantPanelVisible, let selectedChat { - AssistantInspectorView( - chat: selectedChat, - onServiceChange: { service in - handleServiceChange(selectedChat, service) - } - ) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - - if previewStateManager.isPreviewVisible { - PreviewPane(stateManager: previewStateManager) + if assistantPanelVisible { + chatSettingsPanel + .transition(.move(edge: .trailing)) } } .animation(.easeInOut(duration: 0.2), value: assistantPanelVisible) @@ -410,7 +403,6 @@ struct ContentView: View { .onAppear { // Install the key monitor once; re-appearing must not stack duplicates. guard keyDownMonitor == nil else { return } - // Add global key monitor for Shift+Enter when search is active 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 @@ -438,6 +430,205 @@ struct ContentView: View { } } + @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 @@ -448,26 +639,18 @@ struct ContentView: View { } private func handleServiceChange(_ chat: ChatEntity, _ newService: APIServiceEntity) { - // The assistant stays selected; only the service it talks to changes. - if let persona = chat.persona, persona.apiService?.objectID != newService.objectID { - persona.apiService = newService - - // Keep the model only when we know the new service offers it; - // otherwise fall back to the service default. - let currentModel = persona.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let knownModels = newService.id.flatMap { ModelsCache.shared.models(for: $0) } ?? [] - if currentModel.isEmpty || !knownModels.contains(currentModel) { - persona.model = AppConstants.defaultModel(for: newService.type) - } + // Per-chat setting: only this chat switches service; the assistant + // (and every other chat using it) is untouched. + chat.apiService = newService - if !newService.imageGenerationSupported, persona.imageGenerationEnabled { - persona.imageGenerationEnabled = false - } + // Keep the chat's model only when we know the new service offers it; + // otherwise fall back to the service default. + let currentModel = chat.gptModel.trimmingCharacters(in: .whitespacesAndNewlines) + let knownModels = newService.id.flatMap { ModelsCache.shared.models(for: $0) } ?? [] + if currentModel.isEmpty || !knownModels.contains(currentModel) { + chat.gptModel = AppConstants.defaultModel(for: newService.type) } - chat.apiService = newService - let personaModel = chat.persona?.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - chat.gptModel = personaModel.isEmpty ? AppConstants.defaultModel(for: newService.type) : personaModel chat.objectWillChange.send() try? viewContext.save() diff --git a/macai/UI/Preferences/TabGeneralSettingsView.swift b/macai/UI/Preferences/TabGeneralSettingsView.swift index 7368c2e..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 = """ 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/MessageManager.swift b/macai/Utilities/MessageManager.swift index d731cf3..d360f5b 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 } @@ -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 @@ -238,8 +238,7 @@ 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 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 76388d2..886762b 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 @@ -422,6 +482,11 @@ struct macaiApp: App { ContentView() .environment(\.managedObjectContext, persistenceController.container.viewContext) .preferredColorScheme(preferredColorScheme) + .onAppear { + #if DEBUG + WindowMetricsDebug.runIfRequested() + #endif + } } .onChange(of: scenePhase) { _, phase in if phase == .active { diff --git a/macaiTests/Utilities/ChatEntityTests.swift b/macaiTests/Utilities/ChatEntityTests.swift index 59003b4..f11d2f8 100644 --- a/macaiTests/Utilities/ChatEntityTests.swift +++ b/macaiTests/Utilities/ChatEntityTests.swift @@ -160,4 +160,115 @@ final class ChatEntityTests: XCTestCase { 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" + XCTAssertEqual(chat.effectiveModel, "chat-model") + + chat.gptModel = "" + 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 + XCTAssertEqual(chat.effectiveApiService?.objectID, chatService.objectID) + + chat.apiService = nil + XCTAssertEqual(chat.effectiveApiService?.objectID, personaService.objectID) + } + + 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.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") + } } From 76d0f8858172d5e978a44bf3257cd883992f0b48 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sat, 11 Jul 2026 20:06:28 +0300 Subject: [PATCH 03/23] Fix Responses API assistant history encoding --- .../APIHandlers/OpenAIResponsesHandler.swift | 4 +- macaiTests/Utilities/GeminiHandlerTests.swift | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) 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/macaiTests/Utilities/GeminiHandlerTests.swift b/macaiTests/Utilities/GeminiHandlerTests.swift index 25b70b1..2e6e129 100644 --- a/macaiTests/Utilities/GeminiHandlerTests.swift +++ b/macaiTests/Utilities/GeminiHandlerTests.swift @@ -193,6 +193,59 @@ 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" }) + } } private final class URLProtocolStub: URLProtocol { From 93a24d77dbd88df7ab2062a1c286665791da48eb Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sat, 11 Jul 2026 20:29:11 +0300 Subject: [PATCH 04/23] Fix v3 migration and assistant settings inheritance --- macai.xcodeproj/project.pbxproj | 4 +- macai/Models/Models.swift | 23 ++- .../macaiDataModel 4.xcdatamodel/contents | 3 + .../BottomContainer/PersonaSelectorView.swift | 3 + macai/UI/Chat/ChatInputView.swift | 1 + macai/UI/Chat/ChatSettingsView.swift | 1 + macai/UI/ContentView.swift | 6 +- macai/UI/Preferences/TabAIPersonasView.swift | 51 ++++-- macai/Utilities/ChatExporter.swift | 2 +- macai/Utilities/DatabasePatcher.swift | 168 ++++++++++++------ macai/Utilities/MessageManager.swift | 4 +- macaiTests/Utilities/ChatEntityTests.swift | 40 ++++- .../Utilities/DatabaseMigrationTests.swift | 80 +++++++++ .../Utilities/DatabasePatcherTests.swift | 43 ++++- 14 files changed, 345 insertions(+), 84 deletions(-) diff --git a/macai.xcodeproj/project.pbxproj b/macai.xcodeproj/project.pbxproj index 373f534..100bcbc 100644 --- a/macai.xcodeproj/project.pbxproj +++ b/macai.xcodeproj/project.pbxproj @@ -1042,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; @@ -1085,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; diff --git a/macai/Models/Models.swift b/macai/Models/Models.swift index bb35365..5913cdc 100644 --- a/macai/Models/Models.swift +++ b/macai/Models/Models.swift @@ -23,6 +23,9 @@ public class ChatEntity: NSManagedObject, Identifiable { @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? @@ -315,21 +318,34 @@ extension ChatEntity { // writes to the chat only — assistants are edited in Settings → AI Assistants. extension ChatEntity { var effectiveApiService: APIServiceEntity? { - apiService ?? persona?.apiService + if apiServiceOverridden { + return apiService + } + return persona?.apiService ?? apiService } var effectiveModel: String { let chatModel = gptModel.trimmingCharacters(in: .whitespacesAndNewlines) - if chatModel.isEmpty == false { + 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)) @@ -374,6 +390,9 @@ extension ChatEntity { temperatureOverridden = false contextSize = 0 imageGenerationMode = 0 + apiServiceOverridden = false + modelOverridden = false + systemMessageOverridden = false guard let persona else { return } if let personaService = persona.apiService { diff --git a/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents b/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents index 37e5696..530eac2 100644 --- a/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents +++ b/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents @@ -27,6 +27,9 @@ + + + diff --git a/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift b/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift index e7da17b..f9cae80 100644 --- a/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift +++ b/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift @@ -81,6 +81,9 @@ struct PersonaSelectorView: View { 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) ?? "" 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/ChatSettingsView.swift b/macai/UI/Chat/ChatSettingsView.swift index eaead82..55b75a7 100644 --- a/macai/UI/Chat/ChatSettingsView.swift +++ b/macai/UI/Chat/ChatSettingsView.swift @@ -317,6 +317,7 @@ struct ChatSettingsView: View { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.isEmpty == false, chat.gptModel != trimmed else { return } chat.gptModel = trimmed + chat.modelOverridden = true scheduleDebouncedSave() } diff --git a/macai/UI/ContentView.swift b/macai/UI/ContentView.swift index f6c6dba..f39167d 100644 --- a/macai/UI/ContentView.swift +++ b/macai/UI/ContentView.swift @@ -641,15 +641,19 @@ struct ContentView: View { private func handleServiceChange(_ chat: ChatEntity, _ newService: APIServiceEntity) { // 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.apiServiceOverridden = true // Keep the chat's model only when we know the new service offers it; // otherwise fall back to the service default. - let currentModel = chat.gptModel.trimmingCharacters(in: .whitespacesAndNewlines) 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() diff --git a/macai/UI/Preferences/TabAIPersonasView.swift b/macai/UI/Preferences/TabAIPersonasView.swift index 01db2b9..0449554 100644 --- a/macai/UI/Preferences/TabAIPersonasView.swift +++ b/macai/UI/Preferences/TabAIPersonasView.swift @@ -205,6 +205,8 @@ struct PersonaDetailView: View { @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 } @@ -411,8 +413,9 @@ struct PersonaDetailView: View { } Button(persona == nil ? "Create Assistant" : "Update Assistant") { - savePersona() - onSave() + if savePersona() { + onSave() + } } .disabled( name.isEmpty @@ -476,12 +479,18 @@ 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 syncModelSelection() { @@ -536,7 +545,8 @@ struct PersonaDetailView: View { } } - private func savePersona() { + @discardableResult + private func savePersona() -> Bool { let personaToSave = persona ?? PersonaEntity(context: viewContext) personaToSave.name = name personaToSave.color = color.toHex() @@ -582,24 +592,29 @@ 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 } + 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/Utilities/ChatExporter.swift b/macai/Utilities/ChatExporter.swift index 5ad606c..610abfb 100644 --- a/macai/Utilities/ChatExporter.swift +++ b/macai/Utilities/ChatExporter.swift @@ -203,7 +203,7 @@ struct ChatExporter { let context = chat.managedObjectContext return render( chatName: chat.name, - systemMessage: chat.systemMessage, + systemMessage: chat.effectiveSystemMessage, messages: messages, format: format, fileNameResolver: { uuid in diff --git a/macai/Utilities/DatabasePatcher.swift b/macai/Utilities/DatabasePatcher.swift index fc7fe71..33cd7df 100644 --- a/macai/Utilities/DatabasePatcher.swift +++ b/macai/Utilities/DatabasePatcher.swift @@ -263,6 +263,19 @@ class DatabasePatcher { 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, @@ -320,70 +333,104 @@ class DatabasePatcher { 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 { - if persona.apiService == nil { - if let candidates = personaServiceCandidates[persona.objectID], candidates.count == 1, - let serviceID = candidates.first, - let service = services.first(where: { $0.objectID == serviceID }) { - persona.apiService = service - didChange = true + 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 } - applyDefaults(to: persona, service: persona.apiService) } for service in services { - let hasPersona = personas.contains { $0.apiService?.objectID == service.objectID } - if hasPersona == false { + 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 + } - let defaultPersona: PersonaEntity - if let existingDefault = service.defaultPersona { - if existingDefault.apiService == nil || existingDefault.apiService == service { - existingDefault.apiService = service - applyDefaults(to: existingDefault, service: service) - defaultPersona = existingDefault - didChange = true - } else { - let clone = clonePersona(existingDefault, order: maxOrder) - clone.apiService = service - applyDefaults(to: clone, service: service) - defaultPersona = clone - didChange = true - } - } else { - 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 - } - - if service.defaultPersona == nil { - service.defaultPersona = defaultPersona - didChange = true - } + if service.defaultPersona !== defaultPersona { + service.defaultPersona = defaultPersona + 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 = service.defaultPersona { chat.persona = defaultPersona @@ -395,8 +442,25 @@ class DatabasePatcher { } if let persona = chat.persona, let personaService = persona.apiService { - if chat.apiService?.objectID != personaService.objectID { + // 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) { @@ -405,6 +469,8 @@ class DatabasePatcher { } } 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 } } diff --git a/macai/Utilities/MessageManager.swift b/macai/Utilities/MessageManager.swift index d360f5b..842413d 100644 --- a/macai/Utilities/MessageManager.swift +++ b/macai/Utilities/MessageManager.swift @@ -354,14 +354,14 @@ class MessageManager: ObservableObject { 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/macaiTests/Utilities/ChatEntityTests.swift b/macaiTests/Utilities/ChatEntityTests.swift index f11d2f8..df3d140 100644 --- a/macaiTests/Utilities/ChatEntityTests.swift +++ b/macaiTests/Utilities/ChatEntityTests.swift @@ -210,9 +210,10 @@ final class ChatEntityTests: XCTestCase { chat.persona = makePersona(model: "persona-model") chat.gptModel = "chat-model" + chat.modelOverridden = true XCTAssertEqual(chat.effectiveModel, "chat-model") - chat.gptModel = "" + chat.modelOverridden = false XCTAssertEqual(chat.effectiveModel, "persona-model") } @@ -225,12 +226,41 @@ final class ChatEntityTests: XCTestCase { chat.persona = persona chat.apiService = chatService + chat.apiServiceOverridden = true XCTAssertEqual(chat.effectiveApiService?.objectID, chatService.objectID) - chat.apiService = nil + 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() @@ -260,6 +290,9 @@ final class ChatEntityTests: XCTestCase { chat.imageGenerationMode = 2 chat.gptModel = "chat-model" chat.apiService = makeService(name: "Chat Service") + chat.apiServiceOverridden = true + chat.modelOverridden = true + chat.systemMessageOverridden = true chat.resetToAssistantDefaults() @@ -270,5 +303,8 @@ final class ChatEntityTests: XCTestCase { 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) } } diff --git a/macaiTests/Utilities/DatabaseMigrationTests.swift b/macaiTests/Utilities/DatabaseMigrationTests.swift index 63d8feb..1a70e58 100644 --- a/macaiTests/Utilities/DatabaseMigrationTests.swift +++ b/macaiTests/Utilities/DatabaseMigrationTests.swift @@ -171,6 +171,86 @@ 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") + + 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) + } + // MARK: - Helpers private struct StoreURLs { diff --git a/macaiTests/Utilities/DatabasePatcherTests.swift b/macaiTests/Utilities/DatabasePatcherTests.swift index 13c1ac2..02c7eb7 100644 --- a/macaiTests/Utilities/DatabasePatcherTests.swift +++ b/macaiTests/Utilities/DatabasePatcherTests.swift @@ -125,22 +125,35 @@ final class DatabasePatcherTests: XCTestCase { XCTAssertEqual(chat.apiService?.objectID, service.objectID) } - func testMigrationKeepsPersonaUnboundWhenUsedWithMultipleServices() throws { + func testMigrationClonesPersonaUsedWithMultipleServicesAndPreservesChatServices() throws { let serviceA = makeService(name: "OpenAI") let serviceB = makeService(name: "Claude") let persona = makePersona(name: "Shared") + 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)) - // The persona is ambiguous and must stay unbound; each chat keeps its own service. - XCTAssertNil(persona.apiService) + 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) + XCTAssertEqual(serviceA.defaultPersona?.objectID, migratedPersonaA.objectID) + XCTAssertEqual(serviceB.defaultPersona?.objectID, migratedPersonaB.objectID) + 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) - XCTAssertEqual(chatA.persona?.objectID, persona.objectID) - XCTAssertEqual(chatB.persona?.objectID, persona.objectID) } func testMigrationCreatesDefaultPersonaForServiceWithoutOne() throws { @@ -193,6 +206,26 @@ final class DatabasePatcherTests: XCTestCase { 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") From 0dbdbf15d0aebff9c7fb4a966d318f469ccc2430 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 01:44:19 +0300 Subject: [PATCH 05/23] Allow local HTTP endpoints (#150) --- macai/Info.plist | 5 +++++ 1 file changed, 5 insertions(+) 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 From 181e586ae09597299e09aa7fada098fd195722da Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 01:46:02 +0300 Subject: [PATCH 06/23] Support pasting images into chat input (#153) --- .../BottomContainer/MessageInputView.swift | 9 +++++++++ macai/UI/Components/MacaiTextField.swift | 20 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/macai/UI/Chat/BottomContainer/MessageInputView.swift b/macai/UI/Chat/BottomContainer/MessageInputView.swift index 5b38df6..c311882 100644 --- a/macai/UI/Chat/BottomContainer/MessageInputView.swift +++ b/macai/UI/Chat/BottomContainer/MessageInputView.swift @@ -241,6 +241,7 @@ struct MessageInputView: View { minHeight: initialInputSize, maxHeight: maxInputHeight, onEscape: isEditingSystemMessage ? onCancelEdit : nil, + onPasteImage: imageUploadsAllowed ? addPastedImage : nil, onCommit: { guard !isInferenceInProgress else { return } onEnter() @@ -326,6 +327,14 @@ 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 handleDrop(providers: [NSItemProvider]) -> Bool { var didHandleDrop = false diff --git a/macai/UI/Components/MacaiTextField.swift b/macai/UI/Components/MacaiTextField.swift index 99f6e0e..8b51425 100644 --- a/macai/UI/Components/MacaiTextField.swift +++ b/macai/UI/Components/MacaiTextField.swift @@ -23,6 +23,7 @@ struct MacaiTextField: View { var onTab: (() -> Void)? var onBackTab: (() -> Void)? var onEscape: (() -> Void)? + var onPasteImage: ((NSImage) -> Void)? @State private var measuredHeight: CGFloat @State private var placeholderHeight: CGFloat = 0 @@ -38,6 +39,7 @@ struct MacaiTextField: View { onTab: (() -> Void)? = nil, onBackTab: (() -> Void)? = nil, onEscape: (() -> Void)? = nil, + onPasteImage: ((NSImage) -> Void)? = nil, onCommit: (() -> Void)? = nil ) { self.title = String(title) @@ -51,6 +53,7 @@ struct MacaiTextField: View { self.onTab = onTab self.onBackTab = onBackTab self.onEscape = onEscape + self.onPasteImage = onPasteImage _measuredHeight = State(initialValue: minHeight) } @@ -84,7 +87,8 @@ struct MacaiTextField: View { onCommit: onCommit, onTab: onTab, onBackTab: onBackTab, - onEscape: onEscape + onEscape: onEscape, + onPasteImage: onPasteImage ) } .frame(height: min(max(currentHeight, minHeight), maxHeight)) @@ -121,6 +125,7 @@ private struct MacaiTextFieldRep: NSViewRepresentable { var onTab: (() -> Void)? var onBackTab: (() -> Void)? var onEscape: (() -> Void)? + var onPasteImage: ((NSImage) -> Void)? func makeCoordinator() -> Coordinator { Coordinator(self) @@ -129,6 +134,7 @@ private struct MacaiTextFieldRep: NSViewRepresentable { func makeNSView(context: Context) -> NSScrollView { let textView = makeTextView() textView.focusBinding = isFocused + textView.onPasteImage = onPasteImage textView.delegate = context.coordinator textView.string = text if let fontSize { @@ -158,6 +164,7 @@ private struct MacaiTextFieldRep: NSViewRepresentable { guard let textView = scrollView.documentView as? MacaiNSTextView else { return } textView.focusBinding = isFocused + textView.onPasteImage = onPasteImage if textView.string != text, !textView.hasMarkedText() { let selectedRange = textView.selectedRange() @@ -288,6 +295,7 @@ private struct MacaiTextFieldRep: NSViewRepresentable { private final class MacaiNSTextView: NSTextView { var focusBinding: Binding? var onSizeChange: (() -> Void)? + var onPasteImage: ((NSImage) -> Void)? private var isUpdatingSize = false override func becomeFirstResponder() -> Bool { @@ -300,6 +308,16 @@ private final class MacaiNSTextView: NSTextView { return super.resignFirstResponder() } + override func paste(_ sender: Any?) { + if let onPasteImage, + let image = NSImage(pasteboard: .general) + { + onPasteImage(image) + return + } + super.paste(sender) + } + override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) guard window != nil else { return } From 586ca41695f34d47748c4209d5c80d9096d3e81c Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 01:51:08 +0300 Subject: [PATCH 07/23] Add custom API service headers (#156) --- macai/UI/Chat/ChatViewModel.swift | 3 +- .../TabAPIServices/APIServiceDetailView.swift | 35 ++++++++++++- .../APIServiceDetailViewModel.swift | 50 +++++++++++++++++++ .../ButtonTestApiTokenAndModel.swift | 4 +- macai/Utilities/APIHandlers/APIProtocol.swift | 1 + .../APIHandlers/APIServiceConfig.swift | 27 ++++++++++ .../APIHandlers/APIServiceFactory.swift | 6 ++- macai/Utilities/APIServiceManager.swift | 3 +- 8 files changed, 123 insertions(+), 6 deletions(-) diff --git a/macai/UI/Chat/ChatViewModel.swift b/macai/UI/Chat/ChatViewModel.swift index d89f090..d8b3c29 100644 --- a/macai/UI/Chat/ChatViewModel.swift +++ b/macai/UI/Chat/ChatViewModel.swift @@ -312,7 +312,8 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg name: getApiServiceName(), apiUrl: apiServiceUrl, apiKey: apiKey, - model: chat.effectiveModel + model: chat.effectiveModel, + customHeaders: APIServiceHeaderStore.load(for: serviceID) ) } diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift index e581186..5f3b831 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift @@ -112,7 +112,8 @@ struct APIServiceDetailView: View { gptModel: viewModel.testModel, apiUrl: viewModel.url, apiType: viewModel.type, - imageGenerationSupported: viewModel.testImageGenerationEnabled + imageGenerationSupported: viewModel.testImageGenerationEnabled, + customHeaders: viewModel.normalizedCustomHeaders ) } } @@ -160,6 +161,37 @@ struct APIServiceDetailView: View { } .padding(.vertical, 8) + DisclosureGroup("Advanced") { + VStack(alignment: .leading, spacing: 8) { + ForEach($viewModel.customHeaders) { $header in + HStack { + TextField("Header name", text: $header.name) + TextField("Value", text: $header.value) + Button("Remove header", systemImage: "minus.circle", role: .destructive) { + viewModel.removeCustomHeader(id: header.id) + } + .labelStyle(.iconOnly) + } + } + + 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) + + if viewModel.hasInvalidCustomHeaders { + Text("Enter a unique name and value for each header. Names cannot contain spaces or colons.") + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.top, 8) + } + HStack { Text("Default AI Assistant:") .frame(width: 160, alignment: .leading) @@ -196,6 +228,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 36ac6b1..1642146 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift @@ -8,6 +8,18 @@ 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 @@ -20,6 +32,7 @@ class APIServiceDetailViewModel: ObservableObject { @Published var pdfUploadsAllowed: Bool = false @Published var defaultAiPersona: PersonaEntity? @Published var apiKey: String = "" + @Published var customHeaders: [EditableHTTPHeader] = [] @Published var defaultApiConfiguration = AppConstants.defaultApiConfigurations[AppConstants.defaultApiType] init(viewContext: NSManagedObjectContext, apiService: APIServiceEntity?, preferredType: String? = nil) { @@ -49,6 +62,9 @@ class APIServiceDetailViewModel: ObservableObject { defaultApiConfiguration = AppConstants.defaultApiConfigurations[type] 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) ?? "" @@ -91,6 +107,38 @@ class APIServiceDetailViewModel: ObservableObject { return personaEnabled && serviceSupports } + 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 + } + } + + 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 + } + + func addCustomHeader() { + customHeaders.append(EditableHTTPHeader()) + } + + func removeCustomHeader(id: UUID) { + customHeaders.removeAll { $0.id == id } + } + func saveAPIService() { let serviceToSave = apiService ?? APIServiceEntity(context: viewContext) serviceToSave.name = name @@ -138,6 +186,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)") @@ -166,6 +215,7 @@ class APIServiceDetailViewModel: ObservableObject { if let serviceID = serviceToDelete.id { let serviceIDString = serviceID.uuidString try? TokenManager.deleteToken(for: serviceIDString) + APIServiceHeaderStore.delete(for: serviceID) } viewContext.delete(serviceToDelete) diff --git a/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift b/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift index fe7513b..7139dd2 100644 --- a/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift +++ b/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift @@ -14,6 +14,7 @@ struct ButtonTestApiTokenAndModel: View { var apiUrl: String = AppConstants.apiUrlOpenAIResponses var apiType: String = AppConstants.defaultApiType var imageGenerationSupported: Bool = false + var customHeaders: [String: String] = [:] @State var testOk: Bool = false @Environment(\.managedObjectContext) private var viewContext @@ -44,7 +45,8 @@ struct ButtonTestApiTokenAndModel: View { name: apiType, apiUrl: url, apiKey: gptToken, - model: gptModel + model: gptModel, + customHeaders: customHeaders ) let apiService = APIServiceFactory.createAPIService( config: config, diff --git a/macai/Utilities/APIHandlers/APIProtocol.swift b/macai/Utilities/APIHandlers/APIProtocol.swift index 8b90d80..53e0b3a 100644 --- a/macai/Utilities/APIHandlers/APIProtocol.swift +++ b/macai/Utilities/APIHandlers/APIProtocol.swift @@ -42,6 +42,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 { 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/APIServiceManager.swift b/macai/Utilities/APIServiceManager.swift index 1764df2..62b5071 100644 --- a/macai/Utilities/APIServiceManager.swift +++ b/macai/Utilities/APIServiceManager.swift @@ -148,7 +148,8 @@ extension APIServiceManager { name: snapshot.type, apiUrl: snapshot.url, apiKey: apiKey, - model: snapshot.defaultModel + model: snapshot.defaultModel, + customHeaders: APIServiceHeaderStore.load(for: snapshot.serviceID) ) let handler = APIServiceFactory.createAPIService( From 317f1a25739edbd817af30be527ea8936df843aa Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:03:02 +0300 Subject: [PATCH 08/23] Add assistant mention picker (#149) --- .../ChatBottomContainerView.swift | 98 ++++++++++++++++++- .../BottomContainer/PersonaSelectorView.swift | 10 +- 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift index 5593b95..445dd52 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,19 @@ struct ChatBottomContainerView: View { var onStopInference: () -> Void var onCancelEdit: () -> Void var onExpandedStateChange: ((Bool) -> Void)? // Add this line + @State private var isMentionPickerPresented = false + + 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) + } + } init( chat: ChatEntity, @@ -89,7 +108,7 @@ struct ChatBottomContainerView: View { imageUploadsAllowed: imageUploadsAllowed, pdfUploadsAllowed: pdfUploadsAllowed, imageGenerationSupported: imageGenerationSupported, - onEnter: onSendMessage, + onEnter: handleEnter, onAddImage: onAddImage, onAddFile: onAddFile, onStopInference: onStopInference, @@ -99,6 +118,12 @@ struct ChatBottomContainerView: View { .multilineTextAlignment(.leading) .lineLimit(nil) .padding() + .popover(isPresented: $isMentionPickerPresented, arrowEdge: .bottom) { + AssistantMentionPicker( + assistants: filteredMentionAssistants, + onSelect: selectMentionAssistant + ) + } } .border(width: 1, edges: [.top], color: Color(NSColor.windowBackgroundColor).opacity(0.8)) } @@ -120,6 +145,77 @@ struct ChatBottomContainerView: View { .padding(.horizontal) .offset(y: -16) } + .onChange(of: newMessage) { _, value in + updateMentionPresentation(for: value) + } } + + private func updateMentionPresentation(for value: String) { + let query = value.dropFirst() + isMentionPickerPresented = !isEditingSystemMessage + && value.first == "@" + && !query.contains(where: { $0.isWhitespace }) + } + + private func handleEnter() { + if isMentionPickerPresented { + if let assistant = filteredMentionAssistants.first { + selectMentionAssistant(assistant) + } + return + } + onSendMessage() + } + + private func selectMentionAssistant(_ assistant: PersonaEntity) { + PersonaSelectorView.updatePersonaAndSystemMessage(assistant, for: chat) + newMessage = "" + isMentionPickerPresented = false + } +} + +private struct AssistantMentionPicker: View { + let assistants: [PersonaEntity] + let onSelect: (PersonaEntity) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if assistants.isEmpty { + ContentUnavailableView( + "No Assistants Found", + systemImage: "person.crop.circle.badge.questionmark" + ) + .frame(width: 300, height: 120) + } else { + Text("Choose Assistant") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.top, 6) + + ForEach(assistants.prefix(8), id: \.objectID) { assistant in + Button { + onSelect(assistant) + } label: { + 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) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.vertical, 5) + } + } + } + .padding(.vertical, 4) + .frame(width: 320) + } } diff --git a/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift b/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift index f9cae80..714219e 100644 --- a/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift +++ b/macai/UI/Chat/BottomContainer/PersonaSelectorView.swift @@ -73,9 +73,9 @@ 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 @@ -84,9 +84,9 @@ struct PersonaSelectorView: View { chat.apiServiceOverridden = false chat.modelOverridden = false chat.systemMessageOverridden = false - if let service = persona?.apiService { + if let service = persona.apiService { chat.apiService = service - let personaModel = persona?.model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + 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 @@ -106,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) From 66d0092b5456c324779f97106de690c0addeb921 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:07:08 +0300 Subject: [PATCH 09/23] Refine assistant mention picker (#149) --- .../ChatBottomContainerView.swift | 93 ++++++++++++++++--- .../BottomContainer/MessageInputView.swift | 13 ++- macai/UI/Components/MacaiTextField.swift | 20 ++++ 3 files changed, 110 insertions(+), 16 deletions(-) diff --git a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift index 445dd52..ada63fb 100644 --- a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift +++ b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift @@ -31,6 +31,7 @@ struct ChatBottomContainerView: View { 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 "" } @@ -44,6 +45,18 @@ struct ChatBottomContainerView: View { } } + private var mentionCandidates: [PersonaEntity] { + Array(filteredMentionAssistants.prefix(8)) + } + + private var mentionPickerHeight: CGFloat { + mentionCandidates.isEmpty ? 92 : CGFloat(mentionCandidates.count * 32 + 34) + } + + private var mentionPickerLeadingOffset: CGFloat { + imageUploadsAllowed || pdfUploadsAllowed ? 48 : 8 + } + init( chat: ChatEntity, newMessage: Binding, @@ -112,18 +125,30 @@ struct ChatBottomContainerView: View { 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() - .popover(isPresented: $isMentionPickerPresented, arrowEdge: .bottom) { - AssistantMentionPicker( - assistants: filteredMentionAssistants, - onSelect: selectMentionAssistant - ) + .overlay(alignment: .topLeading) { + if isMentionPickerPresented { + AssistantMentionPicker( + assistants: mentionCandidates, + selectedIndex: selectedMentionIndex, + onSelect: selectMentionAssistant + ) + .offset( + x: mentionPickerLeadingOffset, + y: -(mentionPickerHeight + 4) + ) + .transition(.opacity.combined(with: .scale(scale: 0.98, anchor: .bottomLeading))) + } } + .zIndex(1) } .border(width: 1, edges: [.top], color: Color(NSColor.windowBackgroundColor).opacity(0.8)) } @@ -148,6 +173,7 @@ struct ChatBottomContainerView: View { .onChange(of: newMessage) { _, value in updateMentionPresentation(for: value) } + .animation(.easeOut(duration: 0.12), value: isMentionPickerPresented) } @@ -156,11 +182,13 @@ struct ChatBottomContainerView: View { isMentionPickerPresented = !isEditingSystemMessage && value.first == "@" && !query.contains(where: { $0.isWhitespace }) + selectedMentionIndex = 0 } private func handleEnter() { if isMentionPickerPresented { - if let assistant = filteredMentionAssistants.first { + if mentionCandidates.indices.contains(selectedMentionIndex) { + let assistant = mentionCandidates[selectedMentionIndex] selectMentionAssistant(assistant) } return @@ -173,10 +201,31 @@ struct ChatBottomContainerView: View { 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 var body: some View { @@ -186,7 +235,7 @@ private struct AssistantMentionPicker: View { "No Assistants Found", systemImage: "person.crop.circle.badge.questionmark" ) - .frame(width: 300, height: 120) + .frame(width: 232, height: 84) } else { Text("Choose Assistant") .font(.caption) @@ -194,7 +243,7 @@ private struct AssistantMentionPicker: View { .padding(.horizontal, 8) .padding(.top, 6) - ForEach(assistants.prefix(8), id: \.objectID) { assistant in + ForEach(Array(assistants.enumerated()), id: \.element.objectID) { index, assistant in Button { onSelect(assistant) } label: { @@ -203,19 +252,33 @@ private struct AssistantMentionPicker: View { .fill(Color(hex: assistant.color ?? AppConstants.defaultPersonaColor) ?? .accentColor) .frame(width: 10, height: 10) Text(assistant.name ?? "Untitled Assistant") + .lineLimit(1) Spacer() - Text(assistant.apiService?.name ?? "") - .foregroundStyle(.secondary) + if index == selectedIndex { + Image(systemName: "return") + .font(.caption2) + .foregroundStyle(.secondary) + } } .contentShape(.rect) } .buttonStyle(.plain) - .padding(.horizontal, 8) - .padding(.vertical, 5) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background( + Color.accentColor.opacity(index == selectedIndex ? 0.16 : 0), + in: .rect(cornerRadius: 6) + ) } } } - .padding(.vertical, 4) - .frame(width: 320) + .padding(6) + .frame(width: 244) + .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) } } diff --git a/macai/UI/Chat/BottomContainer/MessageInputView.swift b/macai/UI/Chat/BottomContainer/MessageInputView.swift index c311882..f9b5df3 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,9 @@ 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, onCommit: { guard !isInferenceInProgress else { return } diff --git a/macai/UI/Components/MacaiTextField.swift b/macai/UI/Components/MacaiTextField.swift index 8b51425..6fd7296 100644 --- a/macai/UI/Components/MacaiTextField.swift +++ b/macai/UI/Components/MacaiTextField.swift @@ -23,6 +23,8 @@ struct MacaiTextField: View { var onTab: (() -> Void)? var onBackTab: (() -> Void)? var onEscape: (() -> Void)? + var onMoveUp: (() -> Void)? + var onMoveDown: (() -> Void)? var onPasteImage: ((NSImage) -> Void)? @State private var measuredHeight: CGFloat @@ -39,6 +41,8 @@ struct MacaiTextField: View { onTab: (() -> Void)? = nil, onBackTab: (() -> Void)? = nil, onEscape: (() -> Void)? = nil, + onMoveUp: (() -> Void)? = nil, + onMoveDown: (() -> Void)? = nil, onPasteImage: ((NSImage) -> Void)? = nil, onCommit: (() -> Void)? = nil ) { @@ -53,6 +57,8 @@ struct MacaiTextField: View { self.onTab = onTab self.onBackTab = onBackTab self.onEscape = onEscape + self.onMoveUp = onMoveUp + self.onMoveDown = onMoveDown self.onPasteImage = onPasteImage _measuredHeight = State(initialValue: minHeight) } @@ -88,6 +94,8 @@ struct MacaiTextField: View { onTab: onTab, onBackTab: onBackTab, onEscape: onEscape, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, onPasteImage: onPasteImage ) } @@ -125,6 +133,8 @@ private struct MacaiTextFieldRep: NSViewRepresentable { var onTab: (() -> Void)? var onBackTab: (() -> Void)? var onEscape: (() -> Void)? + var onMoveUp: (() -> Void)? + var onMoveDown: (() -> Void)? var onPasteImage: ((NSImage) -> Void)? func makeCoordinator() -> Coordinator { @@ -281,6 +291,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 From 4655e0462bfd98515b29356a61ec901af6da5afd Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:10:35 +0300 Subject: [PATCH 10/23] Improve assistant picker layout and details (#149) --- .../ChatBottomContainerView.swift | 115 ++++++++++++------ 1 file changed, 76 insertions(+), 39 deletions(-) diff --git a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift index ada63fb..b1c3626 100644 --- a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift +++ b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift @@ -46,11 +46,7 @@ struct ChatBottomContainerView: View { } private var mentionCandidates: [PersonaEntity] { - Array(filteredMentionAssistants.prefix(8)) - } - - private var mentionPickerHeight: CGFloat { - mentionCandidates.isEmpty ? 92 : CGFloat(mentionCandidates.count * 32 + 34) + filteredMentionAssistants } private var mentionPickerLeadingOffset: CGFloat { @@ -141,10 +137,10 @@ struct ChatBottomContainerView: View { selectedIndex: selectedMentionIndex, onSelect: selectMentionAssistant ) - .offset( - x: mentionPickerLeadingOffset, - y: -(mentionPickerHeight + 4) - ) + .alignmentGuide(.top) { dimensions in + dimensions[.bottom] + 6 + } + .offset(x: mentionPickerLeadingOffset) .transition(.opacity.combined(with: .scale(scale: 0.98, anchor: .bottomLeading))) } } @@ -237,43 +233,33 @@ private struct AssistantMentionPicker: View { ) .frame(width: 232, height: 84) } else { - Text("Choose Assistant") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.top, 6) - - ForEach(Array(assistants.enumerated()), id: \.element.objectID) { index, assistant in - Button { - onSelect(assistant) - } label: { - HStack { - Circle() - .fill(Color(hex: assistant.color ?? AppConstants.defaultPersonaColor) ?? .accentColor) - .frame(width: 10, height: 10) - Text(assistant.name ?? "Untitled Assistant") - .lineLimit(1) - Spacer() - if index == selectedIndex { - Image(systemName: "return") - .font(.caption2) - .foregroundStyle(.secondary) + 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) } } - .contentShape(.rect) + .padding(3) + } + .frame(maxHeight: 264) + .onChange(of: selectedIndex) { _, index in + guard assistants.indices.contains(index) else { return } + withAnimation(.easeOut(duration: 0.1)) { + proxy.scrollTo(assistants[index].objectID, anchor: .center) + } } - .buttonStyle(.plain) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .background( - Color.accentColor.opacity(index == selectedIndex ? 0.16 : 0), - in: .rect(cornerRadius: 6) - ) } } } .padding(6) - .frame(width: 244) + .frame(width: 280) .background(.regularMaterial, in: .rect(cornerRadius: 10)) .overlay { RoundedRectangle(cornerRadius: 10) @@ -282,3 +268,54 @@ private struct AssistantMentionPicker: View { .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) + } +} From 4bf16061a6ec7de40c4fb289e174979e27a3959f Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:12:25 +0300 Subject: [PATCH 11/23] Fix assistant picker height (#149) --- macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift index b1c3626..8380662 100644 --- a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift +++ b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift @@ -224,6 +224,10 @@ private struct AssistantMentionPicker: View { let selectedIndex: Int let onSelect: (PersonaEntity) -> Void + private var listHeight: CGFloat { + min(max(CGFloat(assistants.count) * 44 + 6, 132), 300) + } + var body: some View { VStack(alignment: .leading, spacing: 4) { if assistants.isEmpty { @@ -248,7 +252,7 @@ private struct AssistantMentionPicker: View { } .padding(3) } - .frame(maxHeight: 264) + .frame(height: listHeight) .onChange(of: selectedIndex) { _, index in guard assistants.indices.contains(index) else { return } withAnimation(.easeOut(duration: 0.1)) { From 1e562b2e01c638517c90a9907687c2312234f27f Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:14:47 +0300 Subject: [PATCH 12/23] Position assistant picker above input (#149) --- .../ChatBottomContainerView.swift | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift index 8380662..e64e76f 100644 --- a/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift +++ b/macai/UI/Chat/BottomContainer/ChatBottomContainerView.swift @@ -53,6 +53,10 @@ struct ChatBottomContainerView: View { imageUploadsAllowed || pdfUploadsAllowed ? 48 : 8 } + private var mentionPickerHeight: CGFloat { + AssistantMentionPicker.totalHeight(for: mentionCandidates.count) + } + init( chat: ChatEntity, newMessage: Binding, @@ -137,10 +141,10 @@ struct ChatBottomContainerView: View { selectedIndex: selectedMentionIndex, onSelect: selectMentionAssistant ) - .alignmentGuide(.top) { dimensions in - dimensions[.bottom] + 6 - } - .offset(x: mentionPickerLeadingOffset) + .offset( + x: mentionPickerLeadingOffset, + y: -(mentionPickerHeight + 6) + ) .transition(.opacity.combined(with: .scale(scale: 0.98, anchor: .bottomLeading))) } } @@ -224,8 +228,16 @@ private struct AssistantMentionPicker: View { 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 { - min(max(CGFloat(assistants.count) * 44 + 6, 132), 300) + Self.listHeight(for: assistants.count) } var body: some View { From 6c09561f6dd749e58477f56883e616f300cf2551 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:18:33 +0300 Subject: [PATCH 13/23] Reject non-image clipboard files (#153) --- macai/UI/Components/MacaiTextField.swift | 40 +++++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/macai/UI/Components/MacaiTextField.swift b/macai/UI/Components/MacaiTextField.swift index 6fd7296..86a3e34 100644 --- a/macai/UI/Components/MacaiTextField.swift +++ b/macai/UI/Components/MacaiTextField.swift @@ -329,15 +329,45 @@ private final class MacaiNSTextView: NSTextView { } override func paste(_ sender: Any?) { - if let onPasteImage, - let image = NSImage(pasteboard: .general) - { - onPasteImage(image) - return + if let onPasteImage { + let pasteboard = NSPasteboard.general + if let image = pastedImage(from: pasteboard) { + onPasteImage(image) + return + } + if pasteboard.availableType(from: [.fileURL]) != nil { + NSSound.beep() + return + } } super.paste(sender) } + private func pastedImage(from pasteboard: NSPasteboard) -> NSImage? { + let fileObjects = pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [NSURL] ?? [] + + if !fileObjects.isEmpty { + guard fileObjects.count == 1 else { return nil } + let url = fileObjects[0] as URL + guard let type = UTType(filenameExtension: url.pathExtension), + type.conforms(to: .image) + else { return nil } + return NSImage(contentsOf: url) + } + + 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 } From 063a653fdf3375cb58812191d51d0744abd2002d Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:20:30 +0300 Subject: [PATCH 14/23] Support capability-aware PDF paste (#153) --- .../BottomContainer/MessageInputView.swift | 24 ++++++ macai/UI/Components/MacaiTextField.swift | 79 +++++++++++++++---- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/macai/UI/Chat/BottomContainer/MessageInputView.swift b/macai/UI/Chat/BottomContainer/MessageInputView.swift index f9b5df3..498d7c5 100644 --- a/macai/UI/Chat/BottomContainer/MessageInputView.swift +++ b/macai/UI/Chat/BottomContainer/MessageInputView.swift @@ -253,6 +253,8 @@ struct MessageInputView: View { onMoveUp: onMoveUp, onMoveDown: onMoveDown, onPasteImage: imageUploadsAllowed ? addPastedImage : nil, + onPastePDF: pdfUploadsAllowed ? addPastedPDF : nil, + handlesPasteAttachments: true, onCommit: { guard !isInferenceInProgress else { return } onEnter() @@ -346,6 +348,28 @@ struct MessageInputView: View { } } + 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/Components/MacaiTextField.swift b/macai/UI/Components/MacaiTextField.swift index 86a3e34..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 @@ -26,6 +32,8 @@ struct MacaiTextField: View { 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 @@ -44,6 +52,8 @@ struct MacaiTextField: View { onMoveUp: (() -> Void)? = nil, onMoveDown: (() -> Void)? = nil, onPasteImage: ((NSImage) -> Void)? = nil, + onPastePDF: ((PastedPDF) -> Void)? = nil, + handlesPasteAttachments: Bool = false, onCommit: (() -> Void)? = nil ) { self.title = String(title) @@ -60,6 +70,8 @@ struct MacaiTextField: View { self.onMoveUp = onMoveUp self.onMoveDown = onMoveDown self.onPasteImage = onPasteImage + self.onPastePDF = onPastePDF + self.handlesPasteAttachments = handlesPasteAttachments _measuredHeight = State(initialValue: minHeight) } @@ -96,7 +108,9 @@ struct MacaiTextField: View { onEscape: onEscape, onMoveUp: onMoveUp, onMoveDown: onMoveDown, - onPasteImage: onPasteImage + onPasteImage: onPasteImage, + onPastePDF: onPastePDF, + handlesPasteAttachments: handlesPasteAttachments ) } .frame(height: min(max(currentHeight, minHeight), maxHeight)) @@ -136,6 +150,8 @@ private struct MacaiTextFieldRep: NSViewRepresentable { var onMoveUp: (() -> Void)? var onMoveDown: (() -> Void)? var onPasteImage: ((NSImage) -> Void)? + var onPastePDF: ((PastedPDF) -> Void)? + var handlesPasteAttachments: Bool func makeCoordinator() -> Coordinator { Coordinator(self) @@ -145,6 +161,8 @@ private struct MacaiTextFieldRep: NSViewRepresentable { 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 { @@ -175,6 +193,8 @@ 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() @@ -316,6 +336,8 @@ 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 { @@ -329,35 +351,60 @@ private final class MacaiNSTextView: NSTextView { } override func paste(_ sender: Any?) { - if let onPasteImage { - let pasteboard = NSPasteboard.general - if let image = pastedImage(from: pasteboard) { + 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) - return + } else { + NSSound.beep() } - if pasteboard.availableType(from: [.fileURL]) != nil { + 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 } + return + } + + if let image = pastedBitmap(from: pasteboard) { + if let onPasteImage { + onPasteImage(image) + } else if handlesPasteAttachments { + NSSound.beep() + } + return } super.paste(sender) } - private func pastedImage(from pasteboard: NSPasteboard) -> NSImage? { + private func pastedFile(from pasteboard: NSPasteboard) -> (url: URL, type: UTType)? { let fileObjects = pasteboard.readObjects( forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true] ) as? [NSURL] ?? [] - if !fileObjects.isEmpty { - guard fileObjects.count == 1 else { return nil } - let url = fileObjects[0] as URL - guard let type = UTType(filenameExtension: url.pathExtension), - type.conforms(to: .image) - else { return nil } - return NSImage(contentsOf: url) - } + 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) From c97d6a0d0b847adaee19b5ac6c9e6aa91d0bc931 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:31:48 +0300 Subject: [PATCH 15/23] Refactor API service connection test --- .../ButtonWithStatusIndicator.swift | 3 + .../TabAPIServices/APIServiceDetailView.swift | 6 +- .../ButtonTestApiTokenAndModel.swift | 109 ++++++++++-------- macai/Utilities/APIHandlers/APIProtocol.swift | 25 +++- .../APIHandlers/PerplexityHandler.swift | 42 +++++++ macaiTests/Utilities/GeminiHandlerTests.swift | 65 +++++++++++ 6 files changed, 198 insertions(+), 52 deletions(-) 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/Preferences/TabAPIServices/APIServiceDetailView.swift b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift index 5f3b831..cda7460 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift @@ -106,13 +106,11 @@ struct APIServiceDetailView: View { } HStack { - ButtonTestApiTokenAndModel( + ButtonTestAPIConnection( lampColor: $lampColor, - gptToken: viewModel.apiKey, - gptModel: viewModel.testModel, + apiKey: viewModel.apiKey, apiUrl: viewModel.url, apiType: viewModel.type, - imageGenerationSupported: viewModel.testImageGenerationEnabled, customHeaders: viewModel.normalizedCustomHeaders ) } diff --git a/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift b/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift index 7139dd2..e426290 100644 --- a/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift +++ b/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift @@ -7,28 +7,37 @@ 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 var customHeaders: [String: String] = [:] - @State var testOk: Bool = false - - @Environment(\.managedObjectContext) private var viewContext + @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() }, - isLoading: lampColor == .yellow, - hasError: lampColor == .red, - errorMessage: nil, - successMessage: "API connection test successful", - isSuccess: testOk - ) + HStack(spacing: 8) { + ButtonWithStatusIndicator( + title: "Test connection", + action: testAPI, + isLoading: lampColor == .yellow, + hasError: lampColor == .red, + errorMessage: errorMessage, + successMessage: statusMessage ?? "API connection test successful", + isSuccess: testOk, + isWarning: hasWarning + ) + + if let statusMessage { + Text(statusMessage) + .font(.caption) + .foregroundStyle(hasWarning ? Color.orange : Color.secondary) + .lineLimit(1) + } + } } private func testAPI() { @@ -39,49 +48,57 @@ struct ButtonTestApiTokenAndModel: View { } lampColor = .yellow - self.testOk = false + testOk = false + hasWarning = false + statusMessage = nil + errorMessage = nil let config = APIServiceConfig( name: apiType, apiUrl: url, - apiKey: gptToken, - model: gptModel, + apiKey: apiKey, + model: "", customHeaders: customHeaders ) - let apiService = APIServiceFactory.createAPIService( - config: config, - imageGenerationSupported: imageGenerationSupported - ) - 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 - case nil: error.localizedDescription - } - showErrorAlert(error: errorMessage) + testOk = true + statusMessage = result.discoveredModelCount.map { + "Connected · \($0) model\($0 == 1 ? "" : "s")" + } ?? "Connected" + } 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/Utilities/APIHandlers/APIProtocol.swift b/macai/Utilities/APIHandlers/APIProtocol.swift index 53e0b3a..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() } @@ -53,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/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/macaiTests/Utilities/GeminiHandlerTests.swift b/macaiTests/Utilities/GeminiHandlerTests.swift index 2e6e129..e7f8ff9 100644 --- a/macaiTests/Utilities/GeminiHandlerTests.swift +++ b/macaiTests/Utilities/GeminiHandlerTests.swift @@ -246,6 +246,71 @@ final class GeminiHandlerTests: XCTestCase { 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 { From d6a50d3d365c056a0e574ef1ce9fc6cd97c03ae5 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:36:53 +0300 Subject: [PATCH 16/23] Move default assistant ownership to assistants --- macai/Configuration/AppConstants.swift | 5 +- macai/Models/Models.swift | 34 ++++++++++ macai/Store/ChatStore.swift | 10 +++ macai/Store/V1ToV3Migration.swift | 9 ++- .../macaiDataModel 4.xcdatamodel/contents | 1 + macai/UI/Preferences/TabAIPersonasView.swift | 44 ++++++++++++ .../TabAPIServices/APIServiceDetailView.swift | 17 ----- .../APIServiceDetailViewModel.swift | 67 +++---------------- .../APIServiceTemplateAddViewModel.swift | 16 ++++- macai/UI/Preferences/TabAPIServicesView.swift | 23 ------- macai/Utilities/DatabasePatcher.swift | 66 ++++++++++++++++-- macaiTests/Utilities/ChatEntityTests.swift | 31 +++++++++ .../Utilities/DatabaseMigrationTests.swift | 10 +++ .../Utilities/DatabasePatcherTests.swift | 14 ++-- 14 files changed, 235 insertions(+), 112 deletions(-) diff --git a/macai/Configuration/AppConstants.swift b/macai/Configuration/AppConstants.swift index 181b5bd..7bb4e4f 100644 --- a/macai/Configuration/AppConstants.swift +++ b/macai/Configuration/AppConstants.swift @@ -55,7 +55,9 @@ struct AppConstants { static let openRouterUploadsPatchCompletedKey = "OpenRouterUploadsPatchCompleted" static let apiServiceMigrationCompletedKey = "APIServiceMigrationCompleted" static let defaultApiServiceMigrationCompletedKey = "DefaultAPIServiceMigrationCompleted" - static let assistantSettingsMigrationCompletedKey = "AssistantSettingsMigrationCompleted" + static let assistantSettingsMigrationCompletedKey = "AssistantSettingsMigrationCompletedV2" + static let assistantSettingsMigrationNoticePendingKey = "AssistantSettingsMigrationNoticePending" + static let selectedPreferencesTabKey = "SelectedPreferencesTab" static let draftTransactionAuthor = "Drafts" static let entityIDBackfillCompletedKey = "EntityIDBackfillCompleted" static let messageSequenceBackfillCompletedKey = "MessageSequenceBackfillCompleted" @@ -322,6 +324,7 @@ 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 diff --git a/macai/Models/Models.swift b/macai/Models/Models.swift index 5913cdc..9512f22 100644 --- a/macai/Models/Models.swift +++ b/macai/Models/Models.swift @@ -423,6 +423,7 @@ extension APIServiceEntity: NSCopying { copy.imageUploadsAllowed = self.imageUploadsAllowed copy.pdfUploadsAllowed = self.pdfUploadsAllowed copy.imageGenerationSupported = self.imageGenerationSupported + // Legacy migration bridge only; API Services no longer own a default assistant. copy.defaultPersona = nil copy.isDefault = false copy.id = UUID() @@ -430,3 +431,36 @@ extension APIServiceEntity: NSCopying { 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/Store/ChatStore.swift b/macai/Store/ChatStore.swift index 675c7e4..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 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/macaiDataModel 4.xcdatamodel/contents b/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents index 530eac2..22ce4f3 100644 --- a/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents +++ b/macai/Store/macaiDataModel.xcdatamodeld/macaiDataModel 4.xcdatamodel/contents @@ -86,6 +86,7 @@ + diff --git a/macai/UI/Preferences/TabAIPersonasView.swift b/macai/UI/Preferences/TabAIPersonasView.swift index 0449554..9a4098c 100644 --- a/macai/UI/Preferences/TabAIPersonasView.swift +++ b/macai/UI/Preferences/TabAIPersonasView.swift @@ -23,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 @@ -46,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 { @@ -78,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 } @@ -95,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 @@ -175,6 +190,17 @@ 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 { @@ -574,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() @@ -604,6 +641,13 @@ struct PersonaDetailView: View { @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() diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift index cda7460..4e8824d 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift @@ -13,12 +13,6 @@ struct APIServiceDetailView: View { @State private var lampColor: Color = .gray @State private var showingDeleteConfirmation: Bool = 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, @@ -190,17 +184,6 @@ struct APIServiceDetailView: View { .padding(.top, 8) } - HStack { - Text("Default AI Assistant:") - .frame(width: 160, alignment: .leading) - - Picker("", selection: $viewModel.defaultAiPersona) { - ForEach(personas) { persona in - Text(persona.name ?? "Untitled").tag(persona) - } - } - } - HStack { if viewModel.apiService != nil { Button(action: { diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift b/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift index 1642146..0682a4f 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceDetailViewModel.swift @@ -30,7 +30,6 @@ class APIServiceDetailViewModel: ObservableObject { @Published var url: String = "" @Published var imageUploadsAllowed: Bool = false @Published var pdfUploadsAllowed: Bool = false - @Published var defaultAiPersona: PersonaEntity? @Published var apiKey: String = "" @Published var customHeaders: [EditableHTTPHeader] = [] @Published var defaultApiConfiguration = AppConstants.defaultApiConfigurations[AppConstants.defaultApiType] @@ -58,7 +57,6 @@ class APIServiceDetailViewModel: ObservableObject { url = service.url?.absoluteString ?? "" imageUploadsAllowed = service.imageUploadsAllowed pdfUploadsAllowed = service.pdfUploadsAllowed - defaultAiPersona = service.defaultPersona defaultApiConfiguration = AppConstants.defaultApiConfigurations[type] if let serviceID = service.id { @@ -90,23 +88,6 @@ class APIServiceDetailViewModel: ObservableObject { } } - var testModel: String { - if let persona = defaultAiPersona, - let model = persona.model, - model.isEmpty == false { - return model - } - return defaultApiConfiguration?.defaultModel ?? AppConstants.defaultPrimaryModel - } - - var testImageGenerationEnabled: Bool { - let personaEnabled = defaultAiPersona?.imageGenerationEnabled ?? false - let serviceSupports = apiService?.imageGenerationSupported - ?? defaultApiConfiguration?.imageGenerationSupported - ?? false - return personaEnabled && serviceSupports - } - var normalizedCustomHeaders: [String: String] { customHeaders.reduce(into: [:]) { result, header in let name = header.name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -146,22 +127,6 @@ class APIServiceDetailViewModel: ObservableObject { serviceToSave.url = URL(string: url) serviceToSave.imageUploadsAllowed = imageUploadsAllowed serviceToSave.pdfUploadsAllowed = pdfUploadsAllowed - if let defaultPersona = defaultAiPersona { - if let boundService = defaultPersona.apiService, boundService != serviceToSave { - // The assistant already belongs to another service. Rebinding it here - // would silently switch every chat using it to this service, so clone - // it instead — same policy as the template-add flow. - let clone = clonePersona(defaultPersona) - clone.apiService = serviceToSave - serviceToSave.defaultPersona = clone - defaultAiPersona = clone - } else { - defaultPersona.apiService = serviceToSave - serviceToSave.defaultPersona = defaultPersona - } - } else { - serviceToSave.defaultPersona = nil - } if serviceToSave.tokenIdentifier == nil || serviceToSave.tokenIdentifier?.isEmpty == true { serviceToSave.tokenIdentifier = UUID().uuidString } @@ -212,6 +177,17 @@ 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) @@ -240,27 +216,6 @@ class APIServiceDetailViewModel: ObservableObject { self.apiKey = token } - private func clonePersona(_ persona: PersonaEntity) -> PersonaEntity { - let clone = PersonaEntity(context: viewContext) - clone.name = persona.name - clone.color = persona.color - clone.systemMessage = persona.systemMessage - clone.temperature = persona.temperature - clone.contextSize = persona.contextSize - clone.useStreamResponse = persona.useStreamResponse - clone.generateChatNames = persona.generateChatNames - clone.imageGenerationEnabled = persona.imageGenerationEnabled - clone.model = persona.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 - return clone - } - var supportsImageUploads: Bool { return AppConstants.defaultApiConfigurations[type]?.imageUploadsSupported ?? false } diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddViewModel.swift b/macai/UI/Preferences/TabAPIServices/APIServiceTemplateAddViewModel.swift index 29314e5..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 @@ -378,7 +378,7 @@ final class APIServiceTemplateAddViewModel: ObservableObject { personaToUse.useStreamResponse = useStreamResponse personaToUse.generateChatNames = generateChatNames personaToUse.imageGenerationEnabled = imageGenerationSupported - service.defaultPersona = personaToUse + ensureDefaultAssistant(personaToUse) do { let identifier = service.id?.uuidString ?? UUID().uuidString @@ -415,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/TabAPIServicesView.swift b/macai/UI/Preferences/TabAPIServicesView.swift index feee461..3b272ad 100644 --- a/macai/UI/Preferences/TabAPIServicesView.swift +++ b/macai/UI/Preferences/TabAPIServicesView.swift @@ -132,7 +132,6 @@ struct TabAPIServicesView: View { Text("API URL: \(service.url?.absoluteString ?? "Not specified")") Text("Image uploads: \(service.imageUploadsAllowed ? "Allowed" : "Disabled")") Text("PDF uploads: \(service.pdfUploadsAllowed ? "Allowed" : "Disabled")") - Text("Default AI Assistant: \(service.defaultPersona?.name ?? "None")") } } else { @@ -151,28 +150,6 @@ struct TabAPIServicesView: View { newService.name = (selectedService.name ?? "") + " Copy" newService.addedDate = Date() - if let defaultPersona = selectedService.defaultPersona { - let newPersona = PersonaEntity(context: viewContext) - newPersona.name = (defaultPersona.name ?? "Assistant") + " Copy" - newPersona.color = defaultPersona.color - newPersona.systemMessage = defaultPersona.systemMessage - newPersona.temperature = defaultPersona.temperature - newPersona.contextSize = defaultPersona.contextSize - newPersona.useStreamResponse = defaultPersona.useStreamResponse - newPersona.generateChatNames = defaultPersona.generateChatNames - newPersona.imageGenerationEnabled = defaultPersona.imageGenerationEnabled - newPersona.model = defaultPersona.model - 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 - newPersona.apiService = newService - newService.defaultPersona = newPersona - } - // Generate new UUID and copy the token let newServiceID = UUID() newService.id = newServiceID diff --git a/macai/Utilities/DatabasePatcher.swift b/macai/Utilities/DatabasePatcher.swift index 33cd7df..b6e3d7a 100644 --- a/macai/Utilities/DatabasePatcher.swift +++ b/macai/Utilities/DatabasePatcher.swift @@ -63,10 +63,12 @@ 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) } } @@ -112,13 +114,24 @@ 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 = 4 @@ -369,6 +382,7 @@ class DatabasePatcher { } } + var preferredPersonaByService: [NSManagedObjectID: PersonaEntity] = [:] for service in services { let boundPersona = (Array(migratedPersonaByPair.values) + personas) .filter { $0.apiService?.objectID == service.objectID } @@ -411,8 +425,44 @@ class DatabasePatcher { didChange = true } - if service.defaultPersona !== defaultPersona { - service.defaultPersona = defaultPersona + 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 } } @@ -432,7 +482,7 @@ class DatabasePatcher { } if chat.persona == nil, let service = chat.apiService { - if let defaultPersona = service.defaultPersona { + if let defaultPersona = preferredPersonaByService[service.objectID] { chat.persona = defaultPersona if chat.systemMessage.isEmpty { chat.systemMessage = defaultPersona.systemMessage ?? AppConstants.chatGptSystemMessage @@ -1026,15 +1076,17 @@ 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 @@ -1059,7 +1111,7 @@ class DatabasePatcher { for chat in existingChats { chat.apiService = apiService - if let defaultPersona = apiService.defaultPersona { + if let defaultPersona = migratedDefaultPersona { chat.persona = defaultPersona chat.gptModel = defaultPersona.model ?? AppConstants.defaultModel(for: apiService.type) } else { diff --git a/macaiTests/Utilities/ChatEntityTests.swift b/macaiTests/Utilities/ChatEntityTests.swift index df3d140..c578893 100644 --- a/macaiTests/Utilities/ChatEntityTests.swift +++ b/macaiTests/Utilities/ChatEntityTests.swift @@ -307,4 +307,35 @@ final class ChatEntityTests: XCTestCase { 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/DatabaseMigrationTests.swift b/macaiTests/Utilities/DatabaseMigrationTests.swift index 1a70e58..a9ddab9 100644 --- a/macaiTests/Utilities/DatabaseMigrationTests.swift +++ b/macaiTests/Utilities/DatabaseMigrationTests.swift @@ -209,6 +209,8 @@ final class DatabaseMigrationTests: XCTestCase { 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") @@ -249,6 +251,14 @@ final class DatabaseMigrationTests: XCTestCase { 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 diff --git a/macaiTests/Utilities/DatabasePatcherTests.swift b/macaiTests/Utilities/DatabasePatcherTests.swift index 02c7eb7..e2173ea 100644 --- a/macaiTests/Utilities/DatabasePatcherTests.swift +++ b/macaiTests/Utilities/DatabasePatcherTests.swift @@ -129,6 +129,7 @@ final class DatabasePatcherTests: XCTestCase { 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) @@ -142,8 +143,10 @@ final class DatabasePatcherTests: XCTestCase { XCTAssertNotEqual(migratedPersonaA.objectID, migratedPersonaB.objectID) XCTAssertEqual(migratedPersonaA.apiService?.objectID, serviceA.objectID) XCTAssertEqual(migratedPersonaB.apiService?.objectID, serviceB.objectID) - XCTAssertEqual(serviceA.defaultPersona?.objectID, migratedPersonaA.objectID) - XCTAssertEqual(serviceB.defaultPersona?.objectID, migratedPersonaB.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) @@ -162,9 +165,11 @@ final class DatabasePatcherTests: XCTestCase { XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) - let defaultPersona = try XCTUnwrap(service.defaultPersona) + 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 { @@ -175,7 +180,8 @@ final class DatabasePatcherTests: XCTestCase { XCTAssertTrue(DatabasePatcher.migrateAssistantSettingsToPersonas(context: context)) XCTAssertNotNil(chat.persona) - XCTAssertEqual(chat.persona?.objectID, service.defaultPersona?.objectID) + XCTAssertEqual(chat.persona?.apiService?.objectID, service.objectID) + XCTAssertTrue(chat.persona?.isDefault ?? false) } func testMigrationRestoresServiceForChatWithBoundPersonaOnly() throws { From aec77f97b48c0e5b06452618002a8bd8cf40cef2 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:40:39 +0300 Subject: [PATCH 17/23] Add assistant-first chat creation flow --- macai/UI/ContentView.swift | 181 +++++++++++++++------ macai/UI/Preferences/PreferencesView.swift | 44 ++--- macai/macaiApp.swift | 9 + 3 files changed, 157 insertions(+), 77 deletions(-) diff --git a/macai/UI/ContentView.swift b/macai/UI/ContentView.swift index f39167d..985282a 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,6 +46,7 @@ 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 keyDownMonitor: Any? @AppStorage("assistantPanelVisible") private var assistantPanelVisible = false @AppStorage("chatSettingsPanelWidth") private var chatSettingsPanelWidth: Double = 320 @@ -61,6 +64,10 @@ struct ContentView: View { .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 @@ -139,6 +146,21 @@ 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 { if !isSidebarVisible { ToolbarItem(placement: .navigation) { @@ -237,6 +259,17 @@ struct ContentView: View { 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) } @@ -256,10 +289,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 @@ -267,38 +308,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 - if let defaultPersona = service.defaultPersona { - newChat.persona = defaultPersona - newChat.gptModel = defaultPersona.model ?? AppConstants.defaultModel(for: service.type) - newChat.systemMessage = defaultPersona.systemMessage ?? AppConstants.chatGptSystemMessage - } else { - newChat.gptModel = AppConstants.defaultModel(for: service.type) - } - } - else if let defaultService = resolveDefaultAPIService() { - newChat.apiService = defaultService - if let defaultPersona = defaultService.defaultPersona { - newChat.persona = defaultPersona - newChat.gptModel = defaultPersona.model ?? AppConstants.defaultModel(for: defaultService.type) - newChat.systemMessage = defaultPersona.systemMessage ?? AppConstants.chatGptSystemMessage - } else { - newChat.gptModel = AppConstants.defaultModel(for: defaultService.type) - } - } + 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)") } } @@ -318,27 +340,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() { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + openSettings() } func clearSelectedChat() { @@ -715,6 +722,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/macaiApp.swift b/macai/macaiApp.swift index 886762b..ce72863 100644 --- a/macai/macaiApp.swift +++ b/macai/macaiApp.swift @@ -548,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) } From 632a734353a1d89290b93da7c6eee2c3aedb4fe1 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:42:03 +0300 Subject: [PATCH 18/23] Keep API connection button geometry stable --- .../ButtonTestApiTokenAndModel.swift | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift b/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift index e426290..747da89 100644 --- a/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift +++ b/macai/UI/Preferences/TabAPIServices/ButtonTestApiTokenAndModel.swift @@ -19,25 +19,16 @@ struct ButtonTestAPIConnection: View { @State private var errorMessage: String? var body: some View { - HStack(spacing: 8) { - ButtonWithStatusIndicator( - title: "Test connection", - action: testAPI, - isLoading: lampColor == .yellow, - hasError: lampColor == .red, - errorMessage: errorMessage, - successMessage: statusMessage ?? "API connection test successful", - isSuccess: testOk, - isWarning: hasWarning - ) - - if let statusMessage { - Text(statusMessage) - .font(.caption) - .foregroundStyle(hasWarning ? Color.orange : Color.secondary) - .lineLimit(1) - } - } + ButtonWithStatusIndicator( + title: "Test connection", + action: testAPI, + isLoading: lampColor == .yellow, + hasError: lampColor == .red, + errorMessage: errorMessage, + successMessage: statusMessage ?? "API connection test successful", + isSuccess: testOk, + isWarning: hasWarning + ) } private func testAPI() { @@ -68,9 +59,7 @@ struct ButtonTestAPIConnection: View { if result.authenticationVerified { lampColor = .green testOk = true - statusMessage = result.discoveredModelCount.map { - "Connected · \($0) model\($0 == 1 ? "" : "s")" - } ?? "Connected" + statusMessage = "Connection successful" } else { lampColor = .orange hasWarning = true From d3364809b288b546d0a35911bab283d387fa8608 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 13:52:50 +0300 Subject: [PATCH 19/23] Make Advanced disclosure label clickable --- .../TabAPIServices/APIServiceDetailView.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift index 4e8824d..e41b854 100644 --- a/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift +++ b/macai/UI/Preferences/TabAPIServices/APIServiceDetailView.swift @@ -12,6 +12,7 @@ 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 init(viewContext: NSManagedObjectContext, apiService: APIServiceEntity?, preferredType: String? = nil) { let viewModel = APIServiceDetailViewModel( @@ -153,7 +154,7 @@ struct APIServiceDetailView: View { } .padding(.vertical, 8) - DisclosureGroup("Advanced") { + DisclosureGroup(isExpanded: $isAdvancedExpanded) { VStack(alignment: .leading, spacing: 8) { ForEach($viewModel.customHeaders) { $header in HStack { @@ -182,6 +183,13 @@ struct APIServiceDetailView: View { } } .padding(.top, 8) + } label: { + Button("Advanced") { + withAnimation { + isAdvancedExpanded.toggle() + } + } + .buttonStyle(.plain) } HStack { From 27c788e3620f0cadfb14dc4adcd3caf619cdb043 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 14:04:10 +0300 Subject: [PATCH 20/23] Add bulk chat selection and actions --- macai/UI/Chat/ChatViewModel.swift | 8 +- macai/UI/ChatList/ChatListRow.swift | 166 +++------- macai/UI/ChatList/ChatListView.swift | 326 ++++++++++++++++++- macai/UI/ChatList/MessageCell.swift | 31 +- macai/UI/ContentView.swift | 23 +- macai/Utilities/ChatExporter.swift | 47 ++- macai/Utilities/MessageManager.swift | 30 +- macaiTests/Utilities/ChatExporterTests.swift | 34 ++ 8 files changed, 505 insertions(+), 160 deletions(-) diff --git a/macai/UI/Chat/ChatViewModel.swift b/macai/UI/Chat/ChatViewModel.swift index d8b3c29..413359d 100644 --- a/macai/UI/Chat/ChatViewModel.swift +++ b/macai/UI/Chat/ChatViewModel.swift @@ -321,8 +321,12 @@ class ChatViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDeleg 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/ChatList/ChatListRow.swift b/macai/UI/ChatList/ChatListRow.swift index 3569c06..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,28 +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, loadsMessages: false) - ) - } - - 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 { @@ -84,121 +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.shouldGenerateChatNames { - Button(action: { - chatViewModel.regenerateChatName() - }) { + Button(action: { onAction(.regenerateName) }) { Label("Regenerate Name", systemImage: "arrow.clockwise") } } - Button(action: { clearChat(chat) }) { + Button(action: { onAction(.clear) }) { Label("Clear Chat", systemImage: "eraser") } Menu { Button("As Markdown…") { - ChatExporter.exportWithSavePanel(chat: chat, format: .markdown) + onAction(.export(.markdown)) } Button("As Plain Text…") { - ChatExporter.exportWithSavePanel(chat: chat, format: .plainText) + onAction(.export(.plainText)) } } label: { Label("Export Chat", systemImage: "square.and.arrow.up") } Divider() - Button(action: { deleteChat(chat) }) { + Button(action: { onAction(.delete) }) { 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.presentAsSheetOrModal { 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.presentAsSheetOrModal { response in - if response == .alertFirstButtonReturn { - chat.name = textField.stringValue - do { - try viewContext.save() - } - - catch { - print("Error renaming chat: \(error.localizedDescription)") - } - } - } - } - - 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.presentAsSheetOrModal { response in - if response == .alertFirstButtonReturn { - chat.clearMessages() - do { - try viewContext.save() - } catch { - print("Error clearing chat: \(error.localizedDescription)") - } - } - } - } - - 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 30b7b18..f8f2783 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,21 +72,35 @@ 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) { + if isSelectionMode { + selectionHeader + } + + ScrollViewReader { _ in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(filteredChats, id: \.objectID) { chat in + ChatListRow( + chat: chat, + showsAttentionIndicator: attentionStore.contains(chat.id), + isActive: !isSelectionMode && 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) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if isSelectionMode { + bulkActionBar } } .onChange(of: searchText) { _, newValue in @@ -100,5 +116,285 @@ 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 selectionHeader: some View { + HStack { + Text("\(selectedChatIDs.count) selected") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button(allVisibleChatsSelected ? "Deselect All" : "Select All") { + if allVisibleChatsSelected { + selectedChatIDs.subtract(filteredChats.map(\.objectID)) + } + else { + selectedChatIDs.formUnion(filteredChats.map(\.objectID)) + } + } + .buttonStyle(.plain) + .font(.caption) + .disabled(filteredChats.isEmpty) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.bar) + .overlay(alignment: .bottom) { Divider() } + } + + private var bulkActionBar: some View { + HStack(spacing: 4) { + bulkButton( + title: selectedChats.allSatisfy(\.isPinned) ? "Unpin Chats" : "Pin Chats", + systemImage: selectedChats.allSatisfy(\.isPinned) ? "pin.slash" : "pin" + ) { + setPinned(selectedChats, to: !selectedChats.allSatisfy(\.isPinned)) + } + + if nameGenerator.isRunning { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity) + .help("Generating chat names") + } + else { + bulkButton(title: "Generate Names", systemImage: "wand.and.stars") { + nameGenerator.generateNames(for: selectedChats, in: viewContext) + } + } + + Menu { + Button("As Markdown…") { exportSelected(as: .markdown) } + Button("As Plain Text…") { exportSelected(as: .plainText) } + } label: { + Image(systemName: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .help("Export Chats") + + bulkButton(title: "Clear Chats", systemImage: "eraser") { + confirmClear(selectedChats) + } + + bulkButton(title: "Delete Chats", systemImage: "trash", role: .destructive) { + confirmDelete(selectedChats) + } + } + .padding(8) + .background(.bar) + .overlay(alignment: .top) { Divider() } + .disabled(selectedChatIDs.isEmpty) + } + + private func bulkButton( + title: String, + systemImage: String, + role: ButtonRole? = nil, + action: @escaping () -> Void + ) -> some View { + Button(role: role, action: action) { + Image(systemName: systemImage) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderless) + .help(title) + } + + 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..0f1e454 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,9 +53,16 @@ struct MessageCell: View, Equatable { var body: some View { Button { - isActive = true + onActivate() } label: { HStack { + if isSelectionMode { + Image(systemName: isBulkSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(isBulkSelected ? Color.accentColor : .secondary) + .accessibilityHidden(true) + } + VStack(alignment: .leading, spacing: 4) { if let personaName { HighlightedText(personaName, highlight: searchText, elementType: "chatlist") @@ -105,6 +117,8 @@ struct MessageCell: View, Equatable { .fill( self.isActive ? Color.accentColor + : isBulkSelected + ? Color.accentColor.opacity(colorScheme == .dark ? 0.24 : 0.14) : self.isHovered ? (colorScheme == .dark ? Color(hex: "#666666")! : Color(hex: "#CCCCCC")!) : Color.clear ) @@ -115,6 +129,8 @@ struct MessageCell: View, Equatable { } .buttonStyle(.borderless) .background(Color.clear) + .accessibilityLabel(chatName.isEmpty ? "Untitled Chat" : chatName) + .accessibilityValue(isSelectionMode ? (isBulkSelected ? "Selected" : "Not selected") : "") } } @@ -164,8 +180,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/ContentView.swift b/macai/UI/ContentView.swift index 985282a..621cb22 100644 --- a/macai/UI/ContentView.swift +++ b/macai/UI/ContentView.swift @@ -47,6 +47,7 @@ struct ContentView: View { @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 @@ -369,7 +370,11 @@ struct ContentView: View { } private var primaryColumn: some View { - ChatListView(selectedChat: $selectedChat, searchText: $searchText) + ChatListView( + selectedChat: $selectedChat, + searchText: $searchText, + isSelectionMode: $isChatSelectionMode + ) .environmentObject(attentionStore) .navigationSplitViewColumnWidth( min: 180, @@ -387,6 +392,22 @@ struct ContentView: View { .labelStyle(.iconOnly) .help("New Chat") } + + ToolbarItem(placement: .navigation) { + Button { + isChatSelectionMode.toggle() + } label: { + if isChatSelectionMode { + Text("Done") + } + else { + Label("Select Chats", systemImage: "checkmark.circle") + .labelStyle(.iconOnly) + } + } + .help(isChatSelectionMode ? "Finish Selecting" : "Select Chats") + .disabled(chats.isEmpty) + } } } } diff --git a/macai/Utilities/ChatExporter.swift b/macai/Utilities/ChatExporter.swift index 610abfb..f5d064b 100644 --- a/macai/Utilities/ChatExporter.swift +++ b/macai/Utilities/ChatExporter.swift @@ -217,6 +217,23 @@ struct ChatExporter { ) } + 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 { @@ -231,6 +248,10 @@ struct ChatExporter { 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 { @@ -269,29 +290,39 @@ struct ChatExporter { // 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 = suggestedFileName(for: chat, format: format) - panel.title = "Export Chat" + 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 chatID = chat.objectID - guard !chatID.isTemporaryID, - let coordinator = chat.managedObjectContext?.persistentStoreCoordinator else { - writeExport(export(chat: chat, format: format), to: url) + 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 { - guard let backgroundChat = try? context.existingObject(with: chatID) as? ChatEntity else { return } - let content = export(chat: backgroundChat, format: format) + 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) } } diff --git a/macai/Utilities/MessageManager.swift b/macai/Utilities/MessageManager.swift index 842413d..77704ab 100644 --- a/macai/Utilities/MessageManager.swift +++ b/macai/Utilities/MessageManager.swift @@ -221,7 +221,11 @@ class MessageManager: ObservableObject { apiService.cancelCurrentRequest() } - func generateChatNameIfNeeded(chat: ChatEntity, force: Bool = false) { + 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 == ""), @@ -230,6 +234,7 @@ class MessageManager: ObservableObject { #if DEBUG print("Chat name not needed, skipping generation") #endif + completion?(.success(nil)) return } @@ -244,13 +249,22 @@ class MessageManager: ObservableObject { [weak self] result in guard let self = self else { return } - switch result { - case .success(let messageBody): - let chatName = MessageManager.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)) + } } } } diff --git a/macaiTests/Utilities/ChatExporterTests.swift b/macaiTests/Utilities/ChatExporterTests.swift index 0268796..ae053ec 100644 --- a/macaiTests/Utilities/ChatExporterTests.swift +++ b/macaiTests/Utilities/ChatExporterTests.swift @@ -221,4 +221,38 @@ class ChatExporterTests: XCTestCase { 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") + } } From 218df5590c305855bbaac04eafdd9f64fa56b323 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 14:26:23 +0300 Subject: [PATCH 21/23] Refine bulk chat selection UI --- macai/UI/ChatList/ChatListView.swift | 145 ++++++++++++++------------- macai/UI/ChatList/MessageCell.swift | 25 +++-- 2 files changed, 89 insertions(+), 81 deletions(-) diff --git a/macai/UI/ChatList/ChatListView.swift b/macai/UI/ChatList/ChatListView.swift index f8f2783..455380a 100644 --- a/macai/UI/ChatList/ChatListView.swift +++ b/macai/UI/ChatList/ChatListView.swift @@ -73,10 +73,6 @@ struct ChatListView: View { var body: some View { VStack(spacing: 0) { - if isSelectionMode { - selectionHeader - } - ScrollViewReader { _ in ScrollView { LazyVStack(alignment: .leading, spacing: 8) { @@ -84,7 +80,7 @@ struct ChatListView: View { ChatListRow( chat: chat, showsAttentionIndicator: attentionStore.contains(chat.id), - isActive: !isSelectionMode && selectedChat?.objectID == chat.objectID, + isActive: selectedChat?.objectID == chat.objectID, isSelectionMode: isSelectionMode, isBulkSelected: selectedChatIDs.contains(chat.objectID), searchText: debouncedSearchText, @@ -142,88 +138,95 @@ struct ChatListView: View { !filteredChats.isEmpty && filteredChats.allSatisfy { selectedChatIDs.contains($0.objectID) } } - private var selectionHeader: some View { + private var bulkActionBar: some View { HStack { - Text("\(selectedChatIDs.count) selected") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button(allVisibleChatsSelected ? "Deselect All" : "Select All") { - if allVisibleChatsSelected { - selectedChatIDs.subtract(filteredChats.map(\.objectID)) + ControlGroup { + Button(action: toggleAllVisibleChats) { + Label( + allVisibleChatsSelected ? "Deselect All" : "Select All", + systemImage: allVisibleChatsSelected ? "checkmark.circle.fill" : "checkmark.circle" + ) } - else { - selectedChatIDs.formUnion(filteredChats.map(\.objectID)) - } - } - .buttonStyle(.plain) - .font(.caption) - .disabled(filteredChats.isEmpty) - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(.bar) - .overlay(alignment: .bottom) { Divider() } - } + .disabled(filteredChats.isEmpty) + .help(allVisibleChatsSelected ? "Deselect All" : "Select All") - private var bulkActionBar: some View { - HStack(spacing: 4) { - bulkButton( - title: selectedChats.allSatisfy(\.isPinned) ? "Unpin Chats" : "Pin Chats", - systemImage: selectedChats.allSatisfy(\.isPinned) ? "pin.slash" : "pin" - ) { - setPinned(selectedChats, to: !selectedChats.allSatisfy(\.isPinned)) - } + Button(action: togglePinForSelectedChats) { + Label( + selectedChats.allSatisfy(\.isPinned) ? "Unpin Chats" : "Pin Chats", + systemImage: selectedChats.allSatisfy(\.isPinned) ? "pin.slash" : "pin" + ) + } + .disabled(selectedChatIDs.isEmpty) + .help(selectedChats.allSatisfy(\.isPinned) ? "Unpin Chats" : "Pin Chats") - if nameGenerator.isRunning { - ProgressView() - .controlSize(.small) - .frame(maxWidth: .infinity) - .help("Generating chat names") - } - else { - bulkButton(title: "Generate Names", systemImage: "wand.and.stars") { - nameGenerator.generateNames(for: selectedChats, in: viewContext) + Button(action: generateNamesForSelectedChats) { + ZStack { + Label("Generate Names", systemImage: "wand.and.stars") + .opacity(nameGenerator.isRunning ? 0 : 1) + if nameGenerator.isRunning { + ProgressView() + .controlSize(.mini) + } + } } - } + .disabled(selectedChatIDs.isEmpty || nameGenerator.isRunning) + .help(nameGenerator.isRunning ? "Generating Names" : "Generate Names") - Menu { - Button("As Markdown…") { exportSelected(as: .markdown) } - Button("As Plain Text…") { exportSelected(as: .plainText) } - } label: { - Image(systemName: "square.and.arrow.up") - .frame(maxWidth: .infinity) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .help("Export Chats") + Menu { + Button("As Markdown…") { exportSelected(as: .markdown) } + Button("As Plain Text…") { exportSelected(as: .plainText) } + } label: { + Label("Export Chats", systemImage: "square.and.arrow.up") + } + .disabled(selectedChatIDs.isEmpty) + .help("Export Chats") - bulkButton(title: "Clear Chats", systemImage: "eraser") { - confirmClear(selectedChats) - } + Button(action: clearSelectedChats) { + Label("Clear Chats", systemImage: "eraser") + } + .disabled(selectedChatIDs.isEmpty) + .help("Clear Chats") - bulkButton(title: "Delete Chats", systemImage: "trash", role: .destructive) { - confirmDelete(selectedChats) + Button(role: .destructive, action: deleteSelectedChats) { + Label("Delete Chats", systemImage: "trash") + } + .disabled(selectedChatIDs.isEmpty) + .help("Delete Chats") } + .labelStyle(.iconOnly) + .controlSize(.small) } .padding(8) + .frame(maxWidth: .infinity) .background(.bar) .overlay(alignment: .top) { Divider() } - .disabled(selectedChatIDs.isEmpty) + .accessibilityElement(children: .contain) + .accessibilityLabel("Actions for \(selectedChatIDs.count) selected chats") } - private func bulkButton( - title: String, - systemImage: String, - role: ButtonRole? = nil, - action: @escaping () -> Void - ) -> some View { - Button(role: role, action: action) { - Image(systemName: systemImage) - .frame(maxWidth: .infinity) + private func toggleAllVisibleChats() { + if allVisibleChatsSelected { + selectedChatIDs.subtract(filteredChats.map(\.objectID)) + } + else { + selectedChatIDs.formUnion(filteredChats.map(\.objectID)) } - .buttonStyle(.borderless) - .help(title) + } + + 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) { diff --git a/macai/UI/ChatList/MessageCell.swift b/macai/UI/ChatList/MessageCell.swift index 0f1e454..e22cbcc 100644 --- a/macai/UI/ChatList/MessageCell.swift +++ b/macai/UI/ChatList/MessageCell.swift @@ -56,13 +56,6 @@ struct MessageCell: View, Equatable { onActivate() } label: { HStack { - if isSelectionMode { - Image(systemName: isBulkSelected ? "checkmark.circle.fill" : "circle") - .font(.title3) - .foregroundStyle(isBulkSelected ? Color.accentColor : .secondary) - .accessibilityHidden(true) - } - VStack(alignment: .leading, spacing: 4) { if let personaName { HighlightedText(personaName, highlight: searchText, elementType: "chatlist") @@ -109,20 +102,31 @@ 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( self.isActive ? Color.accentColor - : isBulkSelected - ? Color.accentColor.opacity(colorScheme == .dark ? 0.24 : 0.14) : self.isHovered ? (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 } @@ -131,6 +135,7 @@ struct MessageCell: View, Equatable { .background(Color.clear) .accessibilityLabel(chatName.isEmpty ? "Untitled Chat" : chatName) .accessibilityValue(isSelectionMode ? (isBulkSelected ? "Selected" : "Not selected") : "") + .accessibilityAddTraits(isSelectionMode && isBulkSelected ? .isSelected : []) } } From 26df83e63832b52f513c2efda64c0dd39a81f9a4 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 14:32:59 +0300 Subject: [PATCH 22/23] Align bulk action bar with macOS conventions --- macai/UI/ChatList/ChatListView.swift | 77 ++++++++++++++++------------ 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/macai/UI/ChatList/ChatListView.swift b/macai/UI/ChatList/ChatListView.swift index 455380a..16a9bd3 100644 --- a/macai/UI/ChatList/ChatListView.swift +++ b/macai/UI/ChatList/ChatListView.swift @@ -139,8 +139,26 @@ struct ChatListView: View { } private var bulkActionBar: some View { - HStack { - ControlGroup { + HStack(spacing: 8) { + Text("\(selectedChatIDs.count) selected") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + 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", @@ -148,55 +166,46 @@ struct ChatListView: View { ) } .disabled(filteredChats.isEmpty) - .help(allVisibleChatsSelected ? "Deselect All" : "Select All") - Button(action: togglePinForSelectedChats) { - Label( - selectedChats.allSatisfy(\.isPinned) ? "Unpin Chats" : "Pin Chats", - systemImage: selectedChats.allSatisfy(\.isPinned) ? "pin.slash" : "pin" - ) - } - .disabled(selectedChatIDs.isEmpty) - .help(selectedChats.allSatisfy(\.isPinned) ? "Unpin Chats" : "Pin Chats") + Divider() Button(action: generateNamesForSelectedChats) { - ZStack { - Label("Generate Names", systemImage: "wand.and.stars") - .opacity(nameGenerator.isRunning ? 0 : 1) - if nameGenerator.isRunning { - ProgressView() - .controlSize(.mini) - } - } + Label( + nameGenerator.isRunning ? "Generating Names…" : "Generate Names", + systemImage: nameGenerator.isRunning ? "hourglass" : "wand.and.stars" + ) } .disabled(selectedChatIDs.isEmpty || nameGenerator.isRunning) - .help(nameGenerator.isRunning ? "Generating Names" : "Generate Names") - Menu { + Menu("Export", systemImage: "square.and.arrow.up") { Button("As Markdown…") { exportSelected(as: .markdown) } Button("As Plain Text…") { exportSelected(as: .plainText) } - } label: { - Label("Export Chats", systemImage: "square.and.arrow.up") } .disabled(selectedChatIDs.isEmpty) - .help("Export Chats") - Button(action: clearSelectedChats) { - Label("Clear Chats", systemImage: "eraser") + Button(role: .destructive, action: clearSelectedChats) { + Label("Clear Chats…", systemImage: "eraser") } .disabled(selectedChatIDs.isEmpty) - .help("Clear Chats") + } 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") - } - .disabled(selectedChatIDs.isEmpty) - .help("Delete Chats") + Button(role: .destructive, action: deleteSelectedChats) { + Label("Delete Chats", systemImage: "trash") } .labelStyle(.iconOnly) - .controlSize(.small) + .buttonStyle(.borderless) + .disabled(selectedChatIDs.isEmpty) + .help("Delete Chats") } - .padding(8) + .controlSize(.regular) + .padding(.horizontal, 10) + .padding(.vertical, 6) .frame(maxWidth: .infinity) .background(.bar) .overlay(alignment: .top) { Divider() } From 86bb9e8e028a83430754b766e65ef62929808227 Mon Sep 17 00:00:00 2001 From: Renat Notfullin Date: Sun, 12 Jul 2026 14:36:20 +0300 Subject: [PATCH 23/23] Remove bulk selection toolbar button --- macai/UI/ChatList/ChatListView.swift | 10 ++++++---- macai/UI/ContentView.swift | 15 --------------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/macai/UI/ChatList/ChatListView.swift b/macai/UI/ChatList/ChatListView.swift index 16a9bd3..de72f59 100644 --- a/macai/UI/ChatList/ChatListView.swift +++ b/macai/UI/ChatList/ChatListView.swift @@ -140,10 +140,12 @@ struct ChatListView: View { private var bulkActionBar: some View { HStack(spacing: 8) { - Text("\(selectedChatIDs.count) selected") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + Button("Done") { + isSelectionMode = false + } + .buttonStyle(.borderless) + .keyboardShortcut(.cancelAction) + .help("Finish Selecting") Spacer(minLength: 4) diff --git a/macai/UI/ContentView.swift b/macai/UI/ContentView.swift index 621cb22..e0809ff 100644 --- a/macai/UI/ContentView.swift +++ b/macai/UI/ContentView.swift @@ -393,21 +393,6 @@ struct ContentView: View { .help("New Chat") } - ToolbarItem(placement: .navigation) { - Button { - isChatSelectionMode.toggle() - } label: { - if isChatSelectionMode { - Text("Done") - } - else { - Label("Select Chats", systemImage: "checkmark.circle") - .labelStyle(.iconOnly) - } - } - .help(isChatSelectionMode ? "Finish Selecting" : "Select Chats") - .disabled(chats.isEmpty) - } } } }