14 Commits

Author SHA1 Message Date
rune 5b99a6f81c Add Anthropic prompt caching (direct + via OpenRouter)
Caches the system prompt/tools and growing conversation history via
cache_control breakpoints, cutting cost and latency on repeated turns.
Covers both the regular chat path and the tool-calling loop
(chatWithToolMessages), which has its own request-building code and was
initially missed. Cost calculation now accounts for cache write/read
pricing instead of treating all input tokens as full price. Verified
live: cache reads grow turn-over-turn in oAI.log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:43:32 +02:00
rune a793fdacc4 Changes... 2026-06-18 11:29:34 +02:00
rune 414cf8cb8c Add missing AccentColor asset
Build settings referenced ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME
= AccentColor but no such color set existed in Assets.xcassets, causing
a build warning. Added it using the app's existing blue accent (#0a7aca,
same as Color.oaiAccent) for consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 11:59:11 +02:00
rune e7c7b9b5c6 Fix combined conversation's model to reflect sources, not the merge model
primaryModel was being set to the model that performed the merge (or,
in AI mode, stamped onto every synthesized message). It should instead
be the most recently used model among the source conversations being
combined.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 11:54:28 +02:00
rune 87535dc2ad Ignore Xcode shared scheme data
Auto-generated by Xcode/xcodebuild when no shared scheme exists yet;
not meant to be tracked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 11:48:15 +02:00
rune 3dff8a8c8e Add combine saved conversations feature (simple + AI-assisted merge)
Lets users multi-select 2+ saved conversations and merge them into one,
either by chronological concatenation or by having the default model
synthesize a single coherent conversation from the source transcripts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 11:45:56 +02:00
rune 00dccd648c README.md edits 2026-06-17 11:00:55 +02:00
rune 92e393ab03 Fix Swift 6 actor-isolation warnings in model inits and services
- Message, Conversation, EmailLog: add nonisolated to inits — plain value
  types have no actor isolation, but the macOS 27 SDK was inferring it
- EncryptionService: replace lazy var encryptionKey (which mutates self and
  gets inferred as @MainActor) with an eagerly-initialized let in init()
- FileLogger: add nonisolated to shared, write, and minimumLevel so they
  are callable from nonisolated AppLogger methods without warnings
- LogLevel.<: add nonisolated to the Comparable conformance method

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:59:07 +02:00
rune 22f745762f Move conversation name to header (macOS document-title style)
The save indicator was sitting in the bottom-right corner of the footer.
Moved it to the center of the header bar, where macOS apps conventionally
show the document/conversation title. An orange dot appears when there are
unsaved changes; clicking saves. Removed the indicator from the footer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:44:32 +02:00
rune b3bb7c4a59 Fix Enter key semantics and add expandable model descriptions
- Replace TextEditor with NativeTextEditor (NSViewRepresentable) so plain
  Enter sends the message and Shift/Cmd+Enter inserts a newline. The old
  TextEditor passed bare Return directly to NSTextView before SwiftUI's
  onKeyPress could intercept it, accidentally making Cmd+Enter send instead.
- Add More…/Less toggle in ModelInfoView for descriptions longer than 250
  characters, with smooth expand/collapse animation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:39:53 +02:00
rune ef1c05c13b Add Claude Fable 5 pricing ($10/$50 per 1M tokens)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:56:05 +02:00
rune f63226b2cc Revert "Add Apple Intelligence provider (Phase 1 — on-device)"
This reverts commit f3a0c45331.
2026-06-16 11:42:51 +02:00
rune f3a0c45331 Add Apple Intelligence provider (Phase 1 — on-device)
- New AppleFoundationProvider using FoundationModels framework (macOS 27+)
- Streaming via streamResponse(to:) → ResponseStream<String> snapshot deltas
- Session built with system prompt + conversation history injected as instructions text
- Full error mapping: context exceeded, guardrail violation, rate limit, availability states
- Settings.Provider.appleOnDevice case wired through ProviderRegistry, Color+Extensions, CreditsView
- inferProvider() detects "apple-" prefix model IDs
- Settings → General: Apple Intelligence section with live availability badge and deep link to System Settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:36:55 +02:00
rune 8451db1142 UI redesign Phase 1: NavigationSplitView with collapsible sidebar
- Replace root VStack with NavigationSplitView (2-column, collapsible sidebar)
- Add SidebarView: new chat button, conversation search, list with swipe actions
- Slim HeaderView to text-only (provider + model + star); remove all icon rows
- Move status pills (Online, MCP, Synced) to footer right side
- Remove version number and shortcut hints from footer
- Add resizable InputBar with drag handle (persisted height) and globe/network.slash online toggle
- Fix Norwegian menu appearing on English systems (CFBundleLocalizations in Info.plist)
- Add View menu (Model Info, History, Stats, Credits, Online Mode toggle ⌘⇧O)
- Add ⌘L as alias for Search Conversations (muscle memory for /load users)
- Add Check for Updates to Help menu with download URL from Gitea API
- Add one-time Intel/Rosetta deprecation warning on first launch
- Swift 6: fix self.Self.isoString() call sites in DatabaseService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:18:48 +02:00
35 changed files with 1620 additions and 632 deletions
+1
View File
@@ -4,6 +4,7 @@
## User settings ## User settings
xcuserdata/ xcuserdata/
xcshareddata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint *.xcscmblueprint
-11
View File
@@ -72,17 +72,6 @@ oAI/
## Building ## Building
### Build Scripts
| Script | Architecture | Output |
|--------|-------------|--------|
| `build.sh` | Apple Silicon (arm64) | Installs directly to `/Applications` |
| `build-dmg.sh` | Apple Silicon (arm64) | `oAI-<version>-AppleSilicon.dmg` on Desktop |
| `build-dmg-universal.sh` | Universal (arm64 + x86_64) | `oAI-<version>-Universal.dmg` on Desktop |
| `build_nb/sv/da/de/en.sh` | Apple Silicon (arm64) | Build + launch in specific language |
All scripts: find Developer ID cert, clean-build via `xcodebuild`, re-sign with `codesign --options runtime --timestamp`, verify. Version is read from `MARKETING_VERSION` in `project.pbxproj`.
### Manual Build Commands ### Manual Build Commands
```bash ```bash
-6
View File
@@ -332,9 +332,6 @@ This means you are free to use, study, modify, and distribute oAI, but any modif
See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html). See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html).
## Development
See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, database schema, and contribution guidelines.
## Author ## Author
@@ -344,9 +341,6 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, datab
- Blog: [https://blog.rune.pm](https://blog.rune.pm) - Blog: [https://blog.rune.pm](https://blog.rune.pm)
- Gitlab.pm: [@rune](https://gitlab.pm/rune) - Gitlab.pm: [@rune](https://gitlab.pm/rune)
## Contributing
Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instructions and project structure.
--- ---
+6 -6
View File
@@ -279,11 +279,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.2; IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 2.3.9; MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -323,11 +323,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.2; IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 2.3.9; MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
BuildableName = "oAI.app"
BlueprintName = "oAI"
ReferencedContainer = "container:oAI.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "nb"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
BuildableName = "oAI.app"
BlueprintName = "oAI"
ReferencedContainer = "container:oAI.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
BuildableName = "oAI.app"
BlueprintName = "oAI"
ReferencedContainer = "container:oAI.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xCA",
"green" : "0x7A",
"red" : "0x0A"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+8
View File
@@ -6,5 +6,13 @@
<string>oAI.help</string> <string>oAI.help</string>
<key>CFBundleHelpBookName</key> <key>CFBundleHelpBookName</key>
<string>oAI Help</string> <string>oAI Help</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>nb</string>
<string>da</string>
<string>de</string>
<string>sv</string>
</array>
</dict> </dict>
</plist> </plist>
+128
View File
@@ -1,6 +1,13 @@
{ {
"sourceLanguage" : "en", "sourceLanguage" : "en",
"strings" : { "strings" : {
"·" : {
"comment" : "A separator between the message count and the date.",
"isCommentAutoGenerated" : true
},
"· %@" : {
},
"(always used)" : { "(always used)" : {
"localizations" : { "localizations" : {
"da" : { "da" : {
@@ -432,6 +439,12 @@
} }
} }
} }
},
"^[%@ message](inflect: true)" : {
},
"^[%@ token](inflect: true)" : {
}, },
"© 2026 [Rune Olsen](https://blog.rune.pm)" : { "© 2026 [Rune Olsen](https://blog.rune.pm)" : {
"comment" : "A copyright notice with the copyright holder's name.", "comment" : "A copyright notice with the copyright holder's name.",
@@ -521,6 +534,7 @@
}, },
"⌘N New • ⌘M Model • ⌘S Save" : { "⌘N New • ⌘M Model • ⌘S Save" : {
"comment" : "A hint that appears on macOS when using keyboard shortcuts.", "comment" : "A hint that appears on macOS when using keyboard shortcuts.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"da" : { "da" : {
@@ -549,6 +563,10 @@
} }
} }
}, },
"⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly." : {
"comment" : "A warning displayed in the settings view.",
"isCommentAutoGenerated" : true
},
"⚠️ Custom prompt active — only this prompt will be sent to the model." : { "⚠️ Custom prompt active — only this prompt will be sent to the model." : {
"localizations" : { "localizations" : {
"da" : { "da" : {
@@ -894,6 +912,12 @@
} }
} }
} }
},
"🧠" : {
},
"1. Open Anytype → Settings → Integrations" : {
}, },
"1. Open Paperless-NGX → Settings → API Tokens" : { "1. Open Paperless-NGX → Settings → API Tokens" : {
"comment" : "A step in the process of getting a Paperless-NGX API token.", "comment" : "A step in the process of getting a Paperless-NGX API token.",
@@ -925,6 +949,10 @@
} }
} }
}, },
"2. Create a new API key" : {
"comment" : "A step in the process of getting an API key from Anytype.",
"isCommentAutoGenerated" : true
},
"2. Create or copy your token" : { "2. Create or copy your token" : {
"comment" : "A step in the process of getting a Paperless-NGX API token.", "comment" : "A step in the process of getting a Paperless-NGX API token.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -1132,6 +1160,9 @@
} }
} }
} }
},
"Agent" : {
}, },
"Agent Skills" : { "Agent Skills" : {
"localizations" : { "localizations" : {
@@ -1160,6 +1191,9 @@
} }
} }
} }
},
"Agents" : {
}, },
"Allow Shell Command?" : { "Allow Shell Command?" : {
"comment" : "A title for a modal that asks the user if they want to allow a shell command.", "comment" : "A title for a modal that asks the user if they want to allow a shell command.",
@@ -1566,6 +1600,9 @@
} }
} }
} }
},
"Category" : {
}, },
"Changing these values affects how the AI generates responses. The defaults work well for most use cases." : { "Changing these values affects how the AI generates responses. The defaults work well for most use cases." : {
"localizations" : { "localizations" : {
@@ -1654,6 +1691,9 @@
} }
} }
} }
},
"Choose an agent from the list to view details and run history" : {
}, },
"Clear All" : { "Clear All" : {
"comment" : "A button to clear all email activity logs.", "comment" : "A button to clear all email activity logs.",
@@ -1918,6 +1958,9 @@
} }
} }
} }
},
"Cost" : {
}, },
"Cost Examples" : { "Cost Examples" : {
"comment" : "A heading for the cost examples of a model.", "comment" : "A heading for the cost examples of a model.",
@@ -2092,6 +2135,9 @@
} }
} }
} }
},
"Disabled" : {
}, },
"Each command will require your approval before running." : { "Each command will require your approval before running." : {
"localizations" : { "localizations" : {
@@ -2468,6 +2514,12 @@
} }
} }
} }
},
"Filter by Category" : {
},
"Generate an API key in your Jarvis settings and paste it above." : {
}, },
"Google (Gemini embedding)" : { "Google (Gemini embedding)" : {
"localizations" : { "localizations" : {
@@ -2526,6 +2578,12 @@
} }
} }
} }
},
"High (~80%)" : {
},
"How to get your API key:" : {
}, },
"How to get your API token:" : { "How to get your API token:" : {
"comment" : "A heading for a section that describes how to get your API token.", "comment" : "A heading for a section that describes how to get your API token.",
@@ -2640,6 +2698,9 @@
} }
} }
} }
},
"Input" : {
}, },
"Large files inflate the system prompt and may hit token limits." : { "Large files inflate the system prompt and may hit token limits." : {
"comment" : "A warning displayed when a user adds a large file to a skill.", "comment" : "A warning displayed when a user adds a large file to a skill.",
@@ -2726,6 +2787,9 @@
} }
} }
} }
},
"Low (~20%)" : {
}, },
"Lowercase letters, numbers, and hyphens only. No spaces." : { "Lowercase letters, numbers, and hyphens only. No spaces." : {
"comment" : "A description of the format of a shortcut's command.", "comment" : "A description of the format of a shortcut's command.",
@@ -2842,6 +2906,9 @@
} }
} }
} }
},
"Medium (~50%)" : {
}, },
"messages" : { "messages" : {
"localizations" : { "localizations" : {
@@ -2870,6 +2937,9 @@
} }
} }
} }
},
"Minimal (~10%)" : {
}, },
"Model Context Protocol" : { "Model Context Protocol" : {
"localizations" : { "localizations" : {
@@ -2928,6 +2998,9 @@
} }
} }
} }
},
"Model thinks internally but reasoning is not shown in chat" : {
}, },
"Multi-provider AI chat client" : { "Multi-provider AI chat client" : {
"comment" : "A description of oAI.", "comment" : "A description of oAI.",
@@ -3018,6 +3091,9 @@
} }
} }
} }
},
"New Chat" : {
}, },
"No credit data available" : { "No credit data available" : {
"comment" : "A message displayed when there is no credit data available.", "comment" : "A message displayed when there is no credit data available.",
@@ -3196,6 +3272,9 @@
} }
} }
} }
},
"No runs yet" : {
}, },
"No shortcuts yet" : { "No shortcuts yet" : {
"comment" : "A message displayed when a user has no shortcuts.", "comment" : "A message displayed when a user has no shortcuts.",
@@ -3347,6 +3426,10 @@
} }
} }
}, },
"oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates." : {
"comment" : "A warning that Intel Macs are no longer supported.",
"isCommentAutoGenerated" : true
},
"Ollama (Local)" : { "Ollama (Local)" : {
"comment" : "A label displayed above the credits information for the local Ollie.", "comment" : "A label displayed above the credits information for the local Ollie.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -3574,6 +3657,9 @@
} }
} }
} }
},
"OpenRouter Balance" : {
}, },
"OpenRouter Credits" : { "OpenRouter Credits" : {
"comment" : "A heading for the user's OpenRouter credits.", "comment" : "A heading for the user's OpenRouter credits.",
@@ -3604,6 +3690,12 @@
} }
} }
} }
},
"Output" : {
},
"Prompt" : {
}, },
"Read access (always enabled)" : { "Read access (always enabled)" : {
"localizations" : { "localizations" : {
@@ -3632,6 +3724,9 @@
} }
} }
} }
},
"Reasoning" : {
}, },
"Remote: %@" : { "Remote: %@" : {
"localizations" : { "localizations" : {
@@ -3690,6 +3785,12 @@
} }
} }
} }
},
"Run History" : {
},
"Run some agents to see usage statistics" : {
}, },
"Running locally — no credits needed!" : { "Running locally — no credits needed!" : {
"comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.", "comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.",
@@ -3721,6 +3822,10 @@
} }
} }
}, },
"Runs" : {
"comment" : "A column header for the number of runs.",
"isCommentAutoGenerated" : true
},
"Security Recommendation" : { "Security Recommendation" : {
"localizations" : { "localizations" : {
"da" : { "da" : {
@@ -3866,6 +3971,9 @@
} }
} }
} }
},
"Sort" : {
}, },
"SSH Key" : { "SSH Key" : {
"localizations" : { "localizations" : {
@@ -4180,6 +4288,9 @@
} }
} }
} }
},
"Thinking…" : {
}, },
"This default prompt is always included to ensure accurate, helpful responses." : { "This default prompt is always included to ensure accurate, helpful responses." : {
"localizations" : { "localizations" : {
@@ -4296,6 +4407,15 @@
} }
} }
} }
},
"Total" : {
},
"Total Credits" : {
},
"Total Used" : {
}, },
"Try adjusting your search or filters" : { "Try adjusting your search or filters" : {
"comment" : "A description of the error that occurs when no models match the user's search.", "comment" : "A description of the error that occurs when no models match the user's search.",
@@ -4444,6 +4564,9 @@
} }
} }
} }
},
"Usage" : {
}, },
"Use @filename to attach files to your message" : { "Use @filename to attach files to your message" : {
"comment" : "A description of how to attach files to a message.", "comment" : "A description of how to attach files to a message.",
@@ -4565,6 +4688,7 @@
}, },
"v%@" : { "v%@" : {
"comment" : "A label showing the current version of oAI.", "comment" : "A label showing the current version of oAI.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"da" : { "da" : {
@@ -4744,6 +4868,10 @@
} }
} }
} }
},
"β" : {
"comment" : "A beta badge.",
"isCommentAutoGenerated" : true
} }
}, },
"version" : "1.1" "version" : "1.1"
+1 -1
View File
@@ -33,7 +33,7 @@ struct Conversation: Identifiable, Codable {
var updatedAt: Date var updatedAt: Date
var primaryModel: String? // Primary model used in this conversation var primaryModel: String? // Primary model used in this conversation
init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
name: String, name: String,
messages: [Message] = [], messages: [Message] = [],
+1 -1
View File
@@ -44,7 +44,7 @@ struct EmailLog: Identifiable, Codable, Equatable {
let responseTime: TimeInterval? // Time to generate response in seconds let responseTime: TimeInterval? // Time to generate response in seconds
let modelId: String? // Model that handled the email let modelId: String? // Model that handled the email
init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
timestamp: Date = Date(), timestamp: Date = Date(),
sender: String, sender: String,
+1 -1
View File
@@ -66,7 +66,7 @@ struct Message: Identifiable, Codable, Equatable {
// Reasoning/thinking content (not persisted in-memory only) // Reasoning/thinking content (not persisted in-memory only)
var thinkingContent: String? = nil var thinkingContent: String? = nil
init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
role: MessageRole, role: MessageRole,
content: String, content: String,
+12
View File
@@ -130,11 +130,23 @@ struct ChatResponse: Codable {
let promptTokens: Int let promptTokens: Int
let completionTokens: Int let completionTokens: Int
let totalTokens: Int let totalTokens: Int
let cacheCreationInputTokens: Int?
let cacheReadInputTokens: Int?
init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil) {
self.promptTokens = promptTokens
self.completionTokens = completionTokens
self.totalTokens = totalTokens
self.cacheCreationInputTokens = cacheCreationInputTokens
self.cacheReadInputTokens = cacheReadInputTokens
}
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens" case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens" case completionTokens = "completion_tokens"
case totalTokens = "total_tokens" case totalTokens = "total_tokens"
case cacheCreationInputTokens = "cache_creation_input_tokens"
case cacheReadInputTokens = "cache_read_input_tokens"
} }
} }
+65 -4
View File
@@ -77,6 +77,15 @@ class AnthropicProvider: AIProvider {
/// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301") /// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301")
/// still inherit the correct pricing tier. /// still inherit the correct pricing tier.
private static let knownModels: [ModelInfo] = [ private static let knownModels: [ModelInfo] = [
// Claude Fable 5
ModelInfo(
id: "claude-fable-5",
name: "Claude Fable 5",
description: "Anthropic's creative and storytelling model",
contextLength: 200_000,
pricing: .init(prompt: 10.0, completion: 50.0),
capabilities: .init(vision: true, tools: true, online: true)
),
// Claude 4.x series // Claude 4.x series
ModelInfo( ModelInfo(
id: "claude-opus-4-6", id: "claude-opus-4-6",
@@ -173,6 +182,7 @@ class AnthropicProvider: AIProvider {
/// Pricing tiers used for fuzzy fallback matching on unknown model IDs. /// Pricing tiers used for fuzzy fallback matching on unknown model IDs.
/// Keyed by model name prefix (longest match wins). /// Keyed by model name prefix (longest match wins).
private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [ private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [
("claude-fable", 10.0, 50.0),
("claude-opus", 15.0, 75.0), ("claude-opus", 15.0, 75.0),
("claude-sonnet", 3.0, 15.0), ("claude-sonnet", 3.0, 15.0),
("claude-haiku", 0.80, 4.0), ("claude-haiku", 0.80, 4.0),
@@ -356,6 +366,19 @@ class AnthropicProvider: AIProvider {
} }
} }
// Mark the last message with a cache breakpoint so the next loop
// iteration (or next turn) can reuse everything up through this one.
if var lastMessage = conversationMessages.popLast() {
if let content = lastMessage["content"] as? String {
lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]]
} else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() {
lastBlock["cache_control"] = ["type": "ephemeral"]
blocks.append(lastBlock)
lastMessage["content"] = blocks
}
conversationMessages.append(lastMessage)
}
var body: [String: Any] = [ var body: [String: Any] = [
"model": model, "model": model,
"messages": conversationMessages, "messages": conversationMessages,
@@ -363,7 +386,9 @@ class AnthropicProvider: AIProvider {
"stream": false "stream": false
] ]
if let systemText = systemText { if let systemText = systemText {
body["system"] = systemText // Array form carries a cache breakpoint; also covers tools, which
// render before system in Anthropic's prefix order.
body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]]
} }
if let temperature = temperature { if let temperature = temperature {
body["temperature"] = temperature body["temperature"] = temperature
@@ -430,6 +455,8 @@ class AnthropicProvider: AIProvider {
var currentId = "" var currentId = ""
var currentModel = request.model var currentModel = request.model
var inputTokens = 0 var inputTokens = 0
var cacheCreationTokens: Int? = nil
var cacheReadTokens: Int? = nil
for try await line in bytes.lines { for try await line in bytes.lines {
// Anthropic SSE: "event: ..." and "data: {...}" // Anthropic SSE: "event: ..." and "data: {...}"
@@ -449,6 +476,11 @@ class AnthropicProvider: AIProvider {
currentModel = message["model"] as? String ?? request.model currentModel = message["model"] as? String ?? request.model
if let usageDict = message["usage"] as? [String: Any] { if let usageDict = message["usage"] as? [String: Any] {
inputTokens = usageDict["input_tokens"] as? Int ?? 0 inputTokens = usageDict["input_tokens"] as? Int ?? 0
cacheCreationTokens = usageDict["cache_creation_input_tokens"] as? Int
cacheReadTokens = usageDict["cache_read_input_tokens"] as? Int
if cacheCreationTokens != nil || cacheReadTokens != nil {
Log.api.info("Anthropic stream cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)")
}
} }
} }
@@ -472,7 +504,13 @@ class AnthropicProvider: AIProvider {
var usage: ChatResponse.Usage? = nil var usage: ChatResponse.Usage? = nil
if let usageDict = event["usage"] as? [String: Any] { if let usageDict = event["usage"] as? [String: Any] {
let outputTokens = usageDict["output_tokens"] as? Int ?? 0 let outputTokens = usageDict["output_tokens"] as? Int ?? 0
usage = ChatResponse.Usage(promptTokens: inputTokens, completionTokens: outputTokens, totalTokens: inputTokens + outputTokens) usage = ChatResponse.Usage(
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens,
cacheCreationInputTokens: cacheCreationTokens,
cacheReadInputTokens: cacheReadTokens
)
} }
continuation.yield(StreamChunk( continuation.yield(StreamChunk(
id: currentId, id: currentId,
@@ -582,6 +620,19 @@ class AnthropicProvider: AIProvider {
} }
} }
// Mark the last message with a cache breakpoint so the next turn can
// reuse everything up through this one as a cached prefix.
if var lastMessage = apiMessages.popLast() {
if let content = lastMessage["content"] as? String {
lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]]
} else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() {
lastBlock["cache_control"] = ["type": "ephemeral"]
blocks.append(lastBlock)
lastMessage["content"] = blocks
}
apiMessages.append(lastMessage)
}
var body: [String: Any] = [ var body: [String: Any] = [
"model": request.model, "model": request.model,
"messages": apiMessages, "messages": apiMessages,
@@ -590,7 +641,10 @@ class AnthropicProvider: AIProvider {
] ]
if let systemText = systemText { if let systemText = systemText {
body["system"] = systemText // Array form (rather than a plain string) carries a cache breakpoint.
// Per Anthropic's render order (tools -> system -> messages), this
// single breakpoint caches the tool definitions too.
body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]]
} }
if let temperature = request.temperature { if let temperature = request.temperature {
body["temperature"] = temperature body["temperature"] = temperature
@@ -665,6 +719,11 @@ class AnthropicProvider: AIProvider {
let usageDict = json["usage"] as? [String: Any] let usageDict = json["usage"] as? [String: Any]
let inputTokens = usageDict?["input_tokens"] as? Int ?? 0 let inputTokens = usageDict?["input_tokens"] as? Int ?? 0
let outputTokens = usageDict?["output_tokens"] as? Int ?? 0 let outputTokens = usageDict?["output_tokens"] as? Int ?? 0
let cacheCreationTokens = usageDict?["cache_creation_input_tokens"] as? Int
let cacheReadTokens = usageDict?["cache_read_input_tokens"] as? Int
if cacheCreationTokens != nil || cacheReadTokens != nil {
Log.api.info("Anthropic cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)")
}
return ChatResponse( return ChatResponse(
id: id, id: id,
@@ -675,7 +734,9 @@ class AnthropicProvider: AIProvider {
usage: ChatResponse.Usage( usage: ChatResponse.Usage(
promptTokens: inputTokens, promptTokens: inputTokens,
completionTokens: outputTokens, completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens totalTokens: inputTokens + outputTokens,
cacheCreationInputTokens: cacheCreationTokens,
cacheReadInputTokens: cacheReadTokens
), ),
created: Date(), created: Date(),
toolCalls: toolCalls.isEmpty ? nil : toolCalls toolCalls: toolCalls.isEmpty ? nil : toolCalls
+18
View File
@@ -48,6 +48,11 @@ struct OpenRouterChatRequest: Codable {
let toolChoice: String? let toolChoice: String?
let modalities: [String]? let modalities: [String]?
let reasoning: ReasoningAPIConfig? let reasoning: ReasoningAPIConfig?
let cacheControl: CacheControl?
struct CacheControl: Codable {
let type: String
}
struct APIMessage: Codable { struct APIMessage: Codable {
let role: String let role: String
@@ -138,6 +143,7 @@ struct OpenRouterChatRequest: Codable {
case toolChoice = "tool_choice" case toolChoice = "tool_choice"
case modalities case modalities
case reasoning case reasoning
case cacheControl = "cache_control"
} }
} }
@@ -225,11 +231,23 @@ struct OpenRouterChatResponse: Codable {
let promptTokens: Int let promptTokens: Int
let completionTokens: Int let completionTokens: Int
let totalTokens: Int let totalTokens: Int
let promptTokensDetails: PromptTokensDetails?
struct PromptTokensDetails: Codable {
let cachedTokens: Int?
let cacheWriteTokens: Int?
enum CodingKeys: String, CodingKey {
case cachedTokens = "cached_tokens"
case cacheWriteTokens = "cache_write_tokens"
}
}
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens" case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens" case completionTokens = "completion_tokens"
case totalTokens = "total_tokens" case totalTokens = "total_tokens"
case promptTokensDetails = "prompt_tokens_details"
} }
} }
} }
+29 -3
View File
@@ -198,6 +198,11 @@ class OpenRouterProvider: AIProvider {
} }
if let maxTokens = maxTokens { body["max_tokens"] = maxTokens } if let maxTokens = maxTokens { body["max_tokens"] = maxTokens }
if let temperature = temperature { body["temperature"] = temperature } if let temperature = temperature { body["temperature"] = temperature }
// Anthropic models require an explicit cache_control opt-in on OpenRouter;
// other providers cache automatically.
if model.hasPrefix("anthropic/") {
body["cache_control"] = ["type": "ephemeral"]
}
var urlRequest = URLRequest(url: url) var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST" urlRequest.httpMethod = "POST"
@@ -388,6 +393,12 @@ class OpenRouterProvider: AIProvider {
ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil) ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil)
} }
// Anthropic models require an explicit cache_control opt-in on OpenRouter;
// other providers (OpenAI, DeepSeek, Gemini, Grok, etc.) cache automatically.
let cacheControl: OpenRouterChatRequest.CacheControl? = effectiveModel.hasPrefix("anthropic/")
? .init(type: "ephemeral")
: nil
return OpenRouterChatRequest( return OpenRouterChatRequest(
model: effectiveModel, model: effectiveModel,
messages: apiMessages, messages: apiMessages,
@@ -398,7 +409,8 @@ class OpenRouterProvider: AIProvider {
tools: request.tools, tools: request.tools,
toolChoice: request.tools != nil ? "auto" : nil, toolChoice: request.tools != nil ? "auto" : nil,
modalities: request.imageGeneration ? ["text", "image"] : nil, modalities: request.imageGeneration ? ["text", "image"] : nil,
reasoning: reasoningConfig reasoning: reasoningConfig,
cacheControl: cacheControl
) )
} }
@@ -416,6 +428,11 @@ class OpenRouterProvider: AIProvider {
let allImages = topLevelImages + blockImages let allImages = topLevelImages + blockImages
let images: [Data]? = allImages.isEmpty ? nil : allImages let images: [Data]? = allImages.isEmpty ? nil : allImages
if let details = apiResponse.usage?.promptTokensDetails,
details.cachedTokens != nil || details.cacheWriteTokens != nil {
Log.api.info("OpenRouter cache usage: model=\(apiResponse.model), created=\(details.cacheWriteTokens ?? 0), read=\(details.cachedTokens ?? 0)")
}
return ChatResponse( return ChatResponse(
id: apiResponse.id, id: apiResponse.id,
model: apiResponse.model, model: apiResponse.model,
@@ -426,7 +443,9 @@ class OpenRouterProvider: AIProvider {
ChatResponse.Usage( ChatResponse.Usage(
promptTokens: usage.promptTokens, promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens, completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens totalTokens: usage.totalTokens,
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
) )
}, },
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)), created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
@@ -446,6 +465,11 @@ class OpenRouterProvider: AIProvider {
let allImages = topLevelImages + blockImages let allImages = topLevelImages + blockImages
let images: [Data]? = allImages.isEmpty ? nil : allImages let images: [Data]? = allImages.isEmpty ? nil : allImages
if let details = apiChunk.usage?.promptTokensDetails,
details.cachedTokens != nil || details.cacheWriteTokens != nil {
Log.api.info("OpenRouter stream cache usage: model=\(apiChunk.model), created=\(details.cacheWriteTokens ?? 0), read=\(details.cachedTokens ?? 0)")
}
return StreamChunk( return StreamChunk(
id: apiChunk.id, id: apiChunk.id,
model: apiChunk.model, model: apiChunk.model,
@@ -460,7 +484,9 @@ class OpenRouterProvider: AIProvider {
ChatResponse.Usage( ChatResponse.Usage(
promptTokens: usage.promptTokens, promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens, completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens totalTokens: usage.totalTokens,
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
) )
} }
) )
+193
View File
@@ -0,0 +1,193 @@
//
// ConversationMergeService.swift
// oAI
//
// Combine multiple saved conversations into one (simple concatenation or AI-assisted merge)
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import Foundation
import os
enum CombineMode: String, Sendable {
case simple
case ai
}
enum MergeError: LocalizedError {
case tooFewConversations
case noDefaultModel
case noAPIKey
case invalidAIResponse(String)
var errorDescription: String? {
switch self {
case .tooFewConversations:
return "Select at least two conversations to combine."
case .noDefaultModel:
return "No default model is configured. Set one in Settings → General → Default Model."
case .noAPIKey:
return "No API key configured for the default provider. Add one in Settings."
case .invalidAIResponse(let snippet):
return "The model's response could not be parsed into a conversation: \(snippet)"
}
}
}
enum ConversationMergeService {
static func merge(
conversationIds: [UUID],
name: String,
mode: CombineMode,
deleteOriginals: Bool
) async throws -> Conversation {
guard conversationIds.count >= 2 else {
throw MergeError.tooFewConversations
}
let sources: [(Conversation, [Message])] = try conversationIds.compactMap { id in
try DatabaseService.shared.loadConversation(id: id)
}
// The model used in the merged conversation should reflect the most recently used
// model across the *source* conversations never the model that performed the merge.
let latestModelId = sources
.flatMap { $0.1 }
.filter { $0.modelId != nil }
.max { $0.timestamp < $1.timestamp }?
.modelId
let mergedMessages: [Message]
switch mode {
case .simple:
mergedMessages = simpleMerge(sources)
case .ai:
mergedMessages = try await aiMerge(sources)
}
let newConversation = try DatabaseService.shared.saveConversation(
id: UUID(),
name: name,
messages: mergedMessages,
primaryModel: latestModelId
)
if deleteOriginals {
for id in conversationIds {
_ = try? DatabaseService.shared.deleteConversation(id: id)
}
}
Log.db.info("Combined \(conversationIds.count) conversations into '\(name)' (mode: \(mode.rawValue), deleteOriginals: \(deleteOriginals))")
return newConversation
}
private static func simpleMerge(_ sources: [(Conversation, [Message])]) -> [Message] {
sources.flatMap { $0.1 }.sorted { $0.timestamp < $1.timestamp }
}
private struct MergedTurn: Codable {
let role: String
let content: String
}
private static func aiMerge(_ sources: [(Conversation, [Message])]) async throws -> [Message] {
let settings = SettingsService.shared
guard let modelId = settings.defaultModel, !modelId.isEmpty else {
throw MergeError.noDefaultModel
}
guard let provider = ProviderRegistry.shared.getProvider(for: settings.defaultProvider) else {
throw MergeError.noAPIKey
}
let transcript = sources.map { conversation, messages -> String in
let body = messages.map { msg -> String in
let label = msg.role == .user ? "**User:**" : "**Assistant:**"
return "\(label) \(msg.content)"
}.joined(separator: "\n\n")
return "### Conversation: \(conversation.name)\n\n\(body)"
}.joined(separator: "\n\n---\n\n")
let mergePrompt = """
Merge the following saved conversation transcripts into a single, coherent conversation. \
Remove redundant or duplicate exchanges, keep the most informative answer when sources overlap, \
preserve important details from each source, and do not invent facts that were not in the originals.
Respond with ONLY a JSON array of message objects in logical order, each in the form \
{"role": "user" or "assistant", "content": "..."}. Do not include any text outside the JSON array.
\(transcript)
"""
let request = ChatRequest(
messages: [Message(role: .user, content: mergePrompt)],
model: modelId,
stream: false,
maxTokens: 4000,
temperature: 0.3,
topP: nil,
systemPrompt: "You are a helpful assistant that merges chat conversation transcripts into one clean, coherent conversation.",
tools: nil,
onlineMode: false,
imageGeneration: false
)
let response: ChatResponse
do {
response = try await provider.chat(request: request)
} catch {
Log.api.error("Conversation merge AI call failed: \(error.localizedDescription)")
throw error
}
let turns = try parseTurns(from: response.content)
// modelId intentionally left nil here: these messages are a synthesized composite,
// not output from a single source model. The conversation's primaryModel (set by the
// caller from the source conversations) is what drives the model shown in the list.
let base = Date()
return turns.enumerated().map { index, turn in
Message(
role: turn.role == "user" ? .user : .assistant,
content: turn.content,
timestamp: base.addingTimeInterval(TimeInterval(index))
)
}
}
private static func parseTurns(from raw: String) throws -> [MergedTurn] {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if text.hasPrefix("```") {
text = text.components(separatedBy: "\n").dropFirst().joined(separator: "\n")
if text.hasSuffix("```") {
text = String(text.dropLast(3))
}
text = text.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard let data = text.data(using: .utf8),
let turns = try? JSONDecoder().decode([MergedTurn].self, from: data),
!turns.isEmpty else {
throw MergeError.invalidAIResponse(String(raw.prefix(200)))
}
return turns
}
}
+41 -27
View File
@@ -134,15 +134,29 @@ final class DatabaseService: Sendable {
nonisolated static let shared = DatabaseService() nonisolated static let shared = DatabaseService()
private let dbQueue: DatabaseQueue private let dbQueue: DatabaseQueue
private let isoFormatter: ISO8601DateFormatter
// Command history limit - keep most recent 5000 entries // Command history limit - keep most recent 5000 entries
private static let maxHistoryEntries = 5000 private nonisolated static let maxHistoryEntries = 5000
// ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable).
private nonisolated static let isoStyle = Date.ISO8601FormatStyle(
dateSeparator: .dash,
dateTimeSeparator: .standard,
timeSeparator: .colon,
timeZoneSeparator: .colon,
includingFractionalSeconds: true,
timeZone: .gmt
)
private nonisolated static func isoString(from date: Date) -> String {
isoStyle.format(date)
}
private nonisolated static func isoDate(from string: String) -> Date? {
(try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
}
nonisolated private init() { nonisolated private init() {
isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fileManager = FileManager.default let fileManager = FileManager.default
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true) let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
@@ -156,7 +170,7 @@ final class DatabaseService: Sendable {
try! migrator.migrate(dbQueue) try! migrator.migrate(dbQueue)
} }
private var migrator: DatabaseMigrator { private nonisolated var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator() var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in migrator.registerMigration("v1") { db in
@@ -375,7 +389,7 @@ final class DatabaseService: Sendable {
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation { nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))") Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
let now = Date() let now = Date()
let nowString = isoFormatter.string(from: now) let nowString = Self.isoString(from: now)
let convRecord = ConversationRecord( let convRecord = ConversationRecord(
id: id.uuidString, id: id.uuidString,
@@ -394,7 +408,7 @@ final class DatabaseService: Sendable {
content: msg.content, content: msg.content,
tokens: msg.tokens, tokens: msg.tokens,
cost: msg.cost, cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp), timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index, sortOrder: index,
modelId: msg.modelId modelId: msg.modelId
) )
@@ -420,7 +434,7 @@ final class DatabaseService: Sendable {
/// Update an existing conversation in-place: rename it, replace all its messages. /// Update an existing conversation in-place: rename it, replace all its messages.
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws { nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
let nowString = isoFormatter.string(from: Date()) let nowString = Self.isoString(from: Date())
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil } guard msg.role != .system else { return nil }
@@ -431,7 +445,7 @@ final class DatabaseService: Sendable {
content: msg.content, content: msg.content,
tokens: msg.tokens, tokens: msg.tokens,
cost: msg.cost, cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp), timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index, sortOrder: index,
modelId: msg.modelId modelId: msg.modelId
) )
@@ -466,7 +480,7 @@ final class DatabaseService: Sendable {
let messages = messageRecords.compactMap { record -> Message? in let messages = messageRecords.compactMap { record -> Message? in
guard let msgId = UUID(uuidString: record.id), guard let msgId = UUID(uuidString: record.id),
let role = MessageRole(rawValue: record.role), let role = MessageRole(rawValue: record.role),
let timestamp = self.isoFormatter.date(from: record.timestamp) let timestamp = Self.isoDate(from: record.timestamp)
else { return nil } else { return nil }
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1 let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
@@ -484,8 +498,8 @@ final class DatabaseService: Sendable {
} }
guard let convId = UUID(uuidString: convRecord.id), guard let convId = UUID(uuidString: convRecord.id),
let createdAt = self.isoFormatter.date(from: convRecord.createdAt), let createdAt = Self.isoDate(from: convRecord.createdAt),
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt) let updatedAt = Self.isoDate(from: convRecord.updatedAt)
else { return nil } else { return nil }
let conversation = Conversation( let conversation = Conversation(
@@ -509,8 +523,8 @@ final class DatabaseService: Sendable {
return records.compactMap { record -> Conversation? in return records.compactMap { record -> Conversation? in
guard let id = UUID(uuidString: record.id), guard let id = UUID(uuidString: record.id),
let createdAt = self.isoFormatter.date(from: record.createdAt), let createdAt = Self.isoDate(from: record.createdAt),
let updatedAt = self.isoFormatter.date(from: record.updatedAt) let updatedAt = Self.isoDate(from: record.updatedAt)
else { return nil } else { return nil }
// Fetch message count without loading all messages // Fetch message count without loading all messages
@@ -524,7 +538,7 @@ final class DatabaseService: Sendable {
.order(Column("sortOrder").desc) .order(Column("sortOrder").desc)
.fetchOne(db) .fetchOne(db)
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt
// Derive primary model: prefer the stored field, fall back to last message's modelId // Derive primary model: prefer the stored field, fall back to last message's modelId
let primaryModel = record.primaryModel ?? lastMsg?.modelId let primaryModel = record.primaryModel ?? lastMsg?.modelId
@@ -574,7 +588,7 @@ final class DatabaseService: Sendable {
convRecord.name = name convRecord.name = name
} }
convRecord.updatedAt = self.isoFormatter.string(from: Date()) convRecord.updatedAt = Self.isoString(from: Date())
try convRecord.update(db) try convRecord.update(db)
if let messages = messages { if let messages = messages {
@@ -589,7 +603,7 @@ final class DatabaseService: Sendable {
content: msg.content, content: msg.content,
tokens: msg.tokens, tokens: msg.tokens,
cost: msg.cost, cost: msg.cost,
timestamp: self.isoFormatter.string(from: msg.timestamp), timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index sortOrder: index
) )
} }
@@ -610,7 +624,7 @@ final class DatabaseService: Sendable {
let record = HistoryRecord( let record = HistoryRecord(
id: UUID().uuidString, id: UUID().uuidString,
input: input, input: input,
timestamp: isoFormatter.string(from: now) timestamp: Self.isoString(from: now)
) )
try? dbQueue.write { db in try? dbQueue.write { db in
@@ -643,7 +657,7 @@ final class DatabaseService: Sendable {
.fetchAll(db) .fetchAll(db)
return records.compactMap { record in return records.compactMap { record in
guard let date = isoFormatter.date(from: record.timestamp) else { guard let date = Self.isoDate(from: record.timestamp) else {
return nil return nil
} }
return (input: record.input, timestamp: date) return (input: record.input, timestamp: date)
@@ -659,7 +673,7 @@ final class DatabaseService: Sendable {
.fetchAll(db) .fetchAll(db)
return records.compactMap { record in return records.compactMap { record in
guard let date = isoFormatter.date(from: record.timestamp) else { guard let date = Self.isoDate(from: record.timestamp) else {
return nil return nil
} }
return (input: record.input, timestamp: date) return (input: record.input, timestamp: date)
@@ -672,7 +686,7 @@ final class DatabaseService: Sendable {
nonisolated func saveEmailLog(_ log: EmailLog) { nonisolated func saveEmailLog(_ log: EmailLog) {
let record = EmailLogRecord( let record = EmailLogRecord(
id: log.id.uuidString, id: log.id.uuidString,
timestamp: isoFormatter.string(from: log.timestamp), timestamp: Self.isoString(from: log.timestamp),
sender: log.sender, sender: log.sender,
subject: log.subject, subject: log.subject,
emailContent: log.emailContent, emailContent: log.emailContent,
@@ -698,7 +712,7 @@ final class DatabaseService: Sendable {
.fetchAll(db) .fetchAll(db)
return records.compactMap { record in return records.compactMap { record in
guard let timestamp = isoFormatter.date(from: record.timestamp), guard let timestamp = Self.isoDate(from: record.timestamp),
let status = EmailLogStatus(rawValue: record.status), let status = EmailLogStatus(rawValue: record.status),
let id = UUID(uuidString: record.id) else { let id = UUID(uuidString: record.id) else {
return nil return nil
@@ -805,7 +819,7 @@ final class DatabaseService: Sendable {
// MARK: - Embedding Operations // MARK: - Embedding Operations
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws { nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
let now = isoFormatter.string(from: Date()) let now = Self.isoString(from: Date())
let record = MessageEmbeddingRecord( let record = MessageEmbeddingRecord(
message_id: messageId.uuidString, message_id: messageId.uuidString,
embedding: embedding, embedding: embedding,
@@ -825,7 +839,7 @@ final class DatabaseService: Sendable {
} }
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws { nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
let now = isoFormatter.string(from: Date()) let now = Self.isoString(from: Date())
let record = ConversationEmbeddingRecord( let record = ConversationEmbeddingRecord(
conversation_id: conversationId.uuidString, conversation_id: conversationId.uuidString,
embedding: embedding, embedding: embedding,
@@ -881,7 +895,7 @@ final class DatabaseService: Sendable {
return Array(results.prefix(limit)) return Array(results.prefix(limit))
} }
private func deserializeEmbedding(_ data: Data) -> [Float] { private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
var embedding: [Float] = [] var embedding: [Float] = []
embedding.reserveCapacity(data.count / 4) embedding.reserveCapacity(data.count / 4)
@@ -905,7 +919,7 @@ final class DatabaseService: Sendable {
model: String?, model: String?,
tokenCount: Int? tokenCount: Int?
) throws { ) throws {
let now = isoFormatter.string(from: Date()) let now = Self.isoString(from: Date())
let record = ConversationSummaryRecord( let record = ConversationSummaryRecord(
id: UUID().uuidString, id: UUID().uuidString,
conversation_id: conversationId.uuidString, conversation_id: conversationId.uuidString,
+2 -2
View File
@@ -71,7 +71,7 @@ enum EmbeddingProvider {
// MARK: - Embedding Service // MARK: - Embedding Service
final class EmbeddingService { final class EmbeddingService {
static let shared = EmbeddingService() nonisolated static let shared = EmbeddingService()
private let settings = SettingsService.shared private let settings = SettingsService.shared
@@ -281,7 +281,7 @@ final class EmbeddingService {
// MARK: - Similarity Calculation // MARK: - Similarity Calculation
/// Calculate cosine similarity between two embeddings /// Calculate cosine similarity between two embeddings
func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { nonisolated func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
guard a.count == b.count else { guard a.count == b.count else {
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)") Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
return 0.0 return 0.0
+10 -13
View File
@@ -29,19 +29,18 @@ import CryptoKit
import IOKit import IOKit
class EncryptionService { class EncryptionService {
static let shared = EncryptionService() nonisolated static let shared = EncryptionService()
private let salt = "oAI-secure-storage-v1" // App-specific salt private let encryptionKey: SymmetricKey
private lazy var encryptionKey: SymmetricKey = {
deriveEncryptionKey()
}()
private init() {} private init() {
self.encryptionKey = Self.deriveEncryptionKey()
}
// MARK: - Public Interface // MARK: - Public Interface
/// Encrypt a string value /// Encrypt a string value
func encrypt(_ value: String) throws -> String { nonisolated func encrypt(_ value: String) throws -> String {
guard let data = value.data(using: .utf8) else { guard let data = value.data(using: .utf8) else {
throw EncryptionError.invalidInput throw EncryptionError.invalidInput
} }
@@ -55,7 +54,7 @@ class EncryptionService {
} }
/// Decrypt a string value /// Decrypt a string value
func decrypt(_ encryptedValue: String) throws -> String { nonisolated func decrypt(_ encryptedValue: String) throws -> String {
guard let data = Data(base64Encoded: encryptedValue) else { guard let data = Data(base64Encoded: encryptedValue) else {
throw EncryptionError.invalidInput throw EncryptionError.invalidInput
} }
@@ -73,19 +72,17 @@ class EncryptionService {
// MARK: - Key Derivation // MARK: - Key Derivation
/// Derive encryption key from machine-specific data /// Derive encryption key from machine-specific data
private func deriveEncryptionKey() -> SymmetricKey { private static func deriveEncryptionKey() -> SymmetricKey {
// Combine machine UUID + bundle ID + salt for key material
let machineUUID = getMachineUUID() let machineUUID = getMachineUUID()
let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI" let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI"
let salt = "oAI-secure-storage-v1"
let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)" let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)"
// Hash to create consistent 256-bit key
let hash = SHA256.hash(data: Data(keyMaterial.utf8)) let hash = SHA256.hash(data: Data(keyMaterial.utf8))
return SymmetricKey(data: hash) return SymmetricKey(data: hash)
} }
/// Get machine-specific UUID (IOPlatformUUID) /// Get machine-specific UUID (IOPlatformUUID)
private func getMachineUUID() -> String { private static func getMachineUUID() -> String {
// Get IOPlatformUUID from IOKit // Get IOPlatformUUID from IOKit
let platformExpert = IOServiceGetMatchingService( let platformExpert = IOServiceGetMatchingService(
kIOMainPortDefault, kIOMainPortDefault,
+1 -1
View File
@@ -212,7 +212,7 @@ class GitSyncService {
// Check if conversation already exists (by ID) // Check if conversation already exists (by ID)
if let existingId = UUID(uuidString: export.id) { if let existingId = UUID(uuidString: export.id) {
if let existing = try? db.loadConversation(id: existingId) { if (try? db.loadConversation(id: existingId)) != nil {
// Already exists - skip // Already exists - skip
log.debug("Skipping existing conversation: \(export.name)") log.debug("Skipping existing conversation: \(export.name)")
skipped += 1 skipped += 1
+18
View File
@@ -300,6 +300,24 @@ class SettingsService {
} }
} }
/// Input bar height in points default 80
var inputBarHeight: Double {
get { cache["inputBarHeight"].flatMap(Double.init) ?? 80.0 }
set {
cache["inputBarHeight"] = String(newValue)
DatabaseService.shared.setSetting(key: "inputBarHeight", value: String(newValue))
}
}
/// Whether the sidebar is visible default true
var sidebarVisible: Bool {
get { cache["sidebarVisible"] != "false" }
set {
cache["sidebarVisible"] = String(newValue)
DatabaseService.shared.setSetting(key: "sidebarVisible", value: String(newValue))
}
}
// MARK: - MCP Permissions // MARK: - MCP Permissions
var mcpCanWriteFiles: Bool { var mcpCanWriteFiles: Bool {
+30
View File
@@ -35,6 +35,11 @@ final class UpdateCheckService {
var updateAvailable: Bool = false var updateAvailable: Bool = false
var latestVersion: String? = nil var latestVersion: String? = nil
var downloadURL: URL? = nil
// Manual check state drives the update alert in ContentView
var isCheckingManually: Bool = false
var manualCheckMessage: String? = nil
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest" private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")! private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
@@ -48,6 +53,24 @@ final class UpdateCheckService {
} }
} }
/// Manual check triggered from the Help menu. Non-blocking result surfaces via manualCheckMessage.
func checkForUpdatesManually() {
guard !isCheckingManually else { return }
isCheckingManually = true
Task.detached(priority: .background) {
await self.performCheck()
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
await MainActor.run {
if self.updateAvailable, let v = self.latestVersion {
self.manualCheckMessage = String(localized: "Version \(v) is available.")
} else {
self.manualCheckMessage = String(localized: "You're up to date (v\(current)).")
}
self.isCheckingManually = false
}
}
}
private func performCheck() async { private func performCheck() async {
guard let url = URL(string: apiURL) else { return } guard let url = URL(string: apiURL) else { return }
@@ -69,9 +92,16 @@ final class UpdateCheckService {
let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
// Extract direct DMG download URL from release assets
let dmgURL: URL? = (release["assets"] as? [[String: Any]])?
.first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") }
.flatMap { $0["browser_download_url"] as? String }
.flatMap { URL(string: $0) }
if isNewer(latestVer, than: currentVer) { if isNewer(latestVer, than: currentVer) {
await MainActor.run { await MainActor.run {
self.latestVersion = latestVer self.latestVersion = latestVer
self.downloadURL = dmgURL
self.updateAvailable = true self.updateAvailable = true
} }
} }
+25 -23
View File
@@ -52,7 +52,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable {
} }
} }
static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { nonisolated static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
lhs.rawValue < rhs.rawValue lhs.rawValue < rhs.rawValue
} }
} }
@@ -60,7 +60,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable {
// MARK: - File Logger // MARK: - File Logger
final class FileLogger: @unchecked Sendable { final class FileLogger: @unchecked Sendable {
static let shared = FileLogger() nonisolated static let shared = FileLogger()
private let fileHandle: FileHandle? private let fileHandle: FileHandle?
private let queue = DispatchQueue(label: "com.oai.filelogger") private let queue = DispatchQueue(label: "com.oai.filelogger")
@@ -70,8 +70,8 @@ final class FileLogger: @unchecked Sendable {
return f return f
}() }()
/// Current minimum log level (read from UserDefaults for thread safety) /// Current minimum log level (backed by UserDefaults thread-safe).
var minimumLevel: LogLevel { nonisolated var minimumLevel: LogLevel {
get { get {
let raw = UserDefaults.standard.integer(forKey: "logLevel") let raw = UserDefaults.standard.integer(forKey: "logLevel")
return LogLevel(rawValue: raw) ?? .info return LogLevel(rawValue: raw) ?? .info
@@ -95,7 +95,7 @@ final class FileLogger: @unchecked Sendable {
fileHandle?.seekToEndOfFile() fileHandle?.seekToEndOfFile()
} }
func write(_ level: LogLevel, category: String, message: String) { nonisolated func write(_ level: LogLevel, category: String, message: String) {
guard level >= minimumLevel else { return } guard level >= minimumLevel else { return }
queue.async { [weak self] in queue.async { [weak self] in
guard let self, let fh = self.fileHandle else { return } guard let self, let fh = self.fileHandle else { return }
@@ -114,41 +114,43 @@ final class FileLogger: @unchecked Sendable {
// MARK: - App Logger (wraps os.Logger + file) // MARK: - App Logger (wraps os.Logger + file)
struct AppLogger { // os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are
let osLogger: Logger // nonisolated FileLogger runs on its own serial queue, os.Logger dispatches to main actor.
struct AppLogger: Sendable {
let subsystem: String
let category: String let category: String
func debug(_ message: String) { nonisolated func debug(_ message: String) {
FileLogger.shared.write(.debug, category: category, message: message) FileLogger.shared.write(.debug, category: category, message: message)
osLogger.debug("\(message, privacy: .public)") Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).debug("\(message, privacy: .public)") }
} }
func info(_ message: String) { nonisolated func info(_ message: String) {
FileLogger.shared.write(.info, category: category, message: message) FileLogger.shared.write(.info, category: category, message: message)
osLogger.info("\(message, privacy: .public)") Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).info("\(message, privacy: .public)") }
} }
func warning(_ message: String) { nonisolated func warning(_ message: String) {
FileLogger.shared.write(.warning, category: category, message: message) FileLogger.shared.write(.warning, category: category, message: message)
osLogger.warning("\(message, privacy: .public)") Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).warning("\(message, privacy: .public)") }
} }
func error(_ message: String) { nonisolated func error(_ message: String) {
FileLogger.shared.write(.error, category: category, message: message) FileLogger.shared.write(.error, category: category, message: message)
osLogger.error("\(message, privacy: .public)") Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") }
} }
} }
// MARK: - Log Namespace // MARK: - Log Namespace
enum Log { enum Log {
private static let subsystem = "com.oai.oAI" private nonisolated static let subsystem = "com.oai.oAI"
static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api") nonisolated static let api = AppLogger(subsystem: subsystem, category: "api")
static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database") nonisolated static let db = AppLogger(subsystem: subsystem, category: "database")
static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp") nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp")
static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings") nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings")
static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search") nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui") nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general") nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
} }
+17 -14
View File
@@ -934,10 +934,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages[index].tokens = usage.completionTokens messages[index].tokens = usage.completionTokens
if let model = selectedModel { if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
: nil
messages[index].cost = cost messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
} }
@@ -1001,10 +998,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages[index].tokens = usage.completionTokens messages[index].tokens = usage.completionTokens
if let model = selectedModel { if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
: nil
messages[index].cost = cost messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
} }
@@ -1313,7 +1307,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Append the complete system prompt (default + custom) // Append the complete system prompt (default + custom)
systemContent += "\n\n---\n\n" + effectiveSystemPrompt systemContent += "\n\n---\n\n" + effectiveSystemPrompt
var messagesToSend: [Message] = memoryEnabled let messagesToSend: [Message] = memoryEnabled
? messages.filter { $0.role != .system } ? messages.filter { $0.role != .system }
: [messages.last(where: { $0.role == .user })].compactMap { $0 } : [messages.last(where: { $0.role == .user })].compactMap { $0 }
@@ -1529,10 +1523,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Calculate cost // Calculate cost
if let usage = totalUsage, let model = selectedModel { if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
: nil
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) { if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost messages[index].cost = cost
} }
@@ -1659,7 +1650,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
if isOverloaded && attempt < maxAttempts && !Task.isCancelled { if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
let delay = Double(1 << attempt) // 2s, 4s, 8s let delay = Double(1 << attempt) // 2s, 4s, 8s
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...") Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
await MainActor.run { _ = await MainActor.run {
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))") showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
} }
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
@@ -2180,6 +2171,18 @@ Don't narrate future actions ("Let me...") - just use the tools.
} }
} }
/// Cost for one response's usage, accounting for Anthropic-style prompt-cache
/// pricing when present: cache writes cost 1.25x the base input rate, cache
/// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder
/// it does not need cache tokens subtracted from it.
private func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000
let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000
let outputCost = Double(usage.completionTokens) * pricing.completion / 1_000_000
return inputCost + cacheReadCost + cacheWriteCost + outputCost
}
/// Summarize a chunk of messages into a concise summary /// Summarize a chunk of messages into a concise summary
private func summarizeMessageChunk(_ messages: [Message]) async -> String? { private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
guard let provider = providerRegistry.getProvider(for: currentProvider), guard let provider = providerRegistry.getProvider(for: currentProvider),
+12 -8
View File
@@ -37,12 +37,11 @@ struct ChatView: View {
HeaderView( HeaderView(
provider: viewModel.currentProvider, provider: viewModel.currentProvider,
model: viewModel.selectedModel, model: viewModel.selectedModel,
stats: viewModel.sessionStats,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled,
mcpStatus: viewModel.mcpStatus,
onModelSelect: onModelSelect, onModelSelect: onModelSelect,
onProviderChange: onProviderChange onProviderChange: onProviderChange,
conversationName: viewModel.currentConversationName,
hasUnsavedChanges: viewModel.hasUnsavedChanges,
onQuickSave: viewModel.quickSave
) )
// Messages // Messages
@@ -85,10 +84,13 @@ struct ChatView: View {
InputBar( InputBar(
text: $viewModel.inputText, text: $viewModel.inputText,
isGenerating: viewModel.isGenerating, isGenerating: viewModel.isGenerating,
mcpStatus: viewModel.mcpStatus,
onlineMode: viewModel.onlineMode, onlineMode: viewModel.onlineMode,
onSend: viewModel.sendMessage, onSend: viewModel.sendMessage,
onCancel: viewModel.cancelGeneration onCancel: viewModel.cancelGeneration,
onToggleOnline: {
viewModel.onlineMode.toggle()
SettingsService.shared.onlineMode = viewModel.onlineMode
}
) )
// Footer // Footer
@@ -96,7 +98,9 @@ struct ChatView: View {
stats: viewModel.sessionStats, stats: viewModel.sessionStats,
conversationName: viewModel.currentConversationName, conversationName: viewModel.currentConversationName,
hasUnsavedChanges: viewModel.hasUnsavedChanges, hasUnsavedChanges: viewModel.hasUnsavedChanges,
onQuickSave: viewModel.quickSave onQuickSave: viewModel.quickSave,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled
) )
} }
.background(Color.oaiBackground) .background(Color.oaiBackground)
+53 -119
View File
@@ -2,7 +2,7 @@
// ContentView.swift // ContentView.swift
// oAI // oAI
// //
// Root navigation container // Root navigation container NavigationSplitView with collapsible sidebar
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen // Copyright (C) 2026 Rune Olsen
@@ -24,30 +24,34 @@
import SwiftUI import SwiftUI
#if os(macOS)
import Darwin // uname, sysctlbyname
#endif
struct ContentView: View { struct ContentView: View {
@Environment(ChatViewModel.self) var chatViewModel @Environment(ChatViewModel.self) var chatViewModel
private var updateService = UpdateCheckService.shared
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var showIntelWarning = false
var body: some View { var body: some View {
@Bindable var vm = chatViewModel @Bindable var vm = chatViewModel
NavigationStack { NavigationSplitView(columnVisibility: $columnVisibility) {
SidebarView()
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
} detail: {
ChatView( ChatView(
onModelSelect: { chatViewModel.showModelSelector = true }, onModelSelect: { chatViewModel.showModelSelector = true },
onProviderChange: { newProvider in onProviderChange: { newProvider in
chatViewModel.changeProvider(newProvider) chatViewModel.changeProvider(newProvider)
} }
) )
.navigationTitle("")
.toolbar {
#if os(macOS)
macOSToolbar
#endif
}
} }
.frame(minWidth: 860, minHeight: 560) .frame(minWidth: 860, minHeight: 560)
#if os(macOS) #if os(macOS)
.onAppear { .onAppear {
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed } NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
checkIntelWarning()
} }
.onKeyPress(.return, phases: .down) { press in .onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.command) { if press.modifiers.contains(.command) {
@@ -65,7 +69,6 @@ struct ContentView: View {
let oldModel = chatViewModel.selectedModel let oldModel = chatViewModel.selectedModel
chatViewModel.selectModel(model) chatViewModel.selectModel(model)
chatViewModel.showModelSelector = false chatViewModel.showModelSelector = false
// Trigger auto-save on model switch
Task { Task {
await chatViewModel.onModelSwitch(from: oldModel, to: model) await chatViewModel.onModelSwitch(from: oldModel, to: model)
} }
@@ -113,125 +116,56 @@ struct ContentView: View {
chatViewModel.inputText = input chatViewModel.inputText = input
}) })
} }
.alert("Intel Mac Support Ending", isPresented: $showIntelWarning) {
Button("Got It") {
UserDefaults.standard.set(true, forKey: "hasShownIntelWarning")
}
} message: {
Text("oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.")
}
.alert("Software Update", isPresented: Binding(
get: { updateService.manualCheckMessage != nil },
set: { if !$0 { updateService.manualCheckMessage = nil } }
)) {
if updateService.updateAvailable {
if let url = updateService.downloadURL {
Button("Download v\(updateService.latestVersion ?? "")") {
NSWorkspace.shared.open(url)
}
}
Button("Release Page") { updateService.openReleasesPage() }
Button("Later", role: .cancel) { }
} else {
Button("OK", role: .cancel) { }
}
} message: {
Text(updateService.manualCheckMessage ?? "")
}
} }
#if os(macOS) #if os(macOS)
@ToolbarContentBuilder private func checkIntelWarning() {
private var macOSToolbar: some ToolbarContent { guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
let settings = SettingsService.shared guard isIntelNative || isRosetta else { return }
let showLabels = settings.showToolbarLabels showIntelWarning = true
let iconSize = settings.toolbarIconSize
ToolbarItemGroup(placement: .automatic) {
// New conversation
Button(action: { chatViewModel.newConversation() }) {
ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, iconSize: iconSize)
} }
.keyboardShortcut("n", modifiers: .command)
.help("New conversation")
Button(action: { chatViewModel.showConversations = true }) { private var isIntelNative: Bool {
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize) var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) {
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
}
return machine.contains("x86_64")
} }
.keyboardShortcut("l", modifiers: .command)
.help("Saved conversations (Cmd+L)")
Button(action: { chatViewModel.showHistory = true }) { private var isRosetta: Bool {
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize) var ret: Int32 = 0
} var size = MemoryLayout<Int32>.size
.keyboardShortcut("h", modifiers: .command) sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
.help("Command history (Cmd+H)") return ret == 1
Spacer()
Button(action: { chatViewModel.showModelSelector = true }) {
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize)
}
.keyboardShortcut("m", modifiers: .command)
.help("Select AI model (Cmd+M)")
Button(action: {
if let model = chatViewModel.selectedModel {
chatViewModel.modelInfoTarget = model
}
}) {
ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, iconSize: iconSize)
}
.keyboardShortcut("i", modifiers: .command)
.help("Model info (Cmd+I)")
.disabled(chatViewModel.selectedModel == nil)
Button(action: { chatViewModel.showStats = true }) {
ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, iconSize: iconSize)
}
.help("Session statistics")
Button(action: { chatViewModel.showCredits = true }) {
ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, iconSize: iconSize)
}
.help("Check API credits")
Spacer()
Button(action: { chatViewModel.showSettings = true }) {
ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, iconSize: iconSize)
}
.keyboardShortcut(",", modifiers: .command)
.help("Settings (Cmd+,)")
Button(action: { chatViewModel.showHelp = true }) {
ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize)
}
.keyboardShortcut("/", modifiers: .command)
.help("Help & commands (Cmd+/)")
}
} }
#endif #endif
}
// Helper view for toolbar labels
struct ToolbarLabel: View {
let title: LocalizedStringKey
let systemImage: String
let showLabels: Bool
let iconSize: Double
// imageScale for the original range (32); explicit font size for the new extra-large range (>32)
private var scale: Image.Scale {
switch iconSize {
case ...18: return .small
case 19...24: return .medium
default: return .large
}
}
var body: some View {
if iconSize > 32 {
// Extra-large: explicit font size above the system .large ceiling
// Offset by 16 so slider 3418pt, 3620pt, 3822pt, 4024pt
if showLabels {
Label(title, systemImage: systemImage)
.labelStyle(.titleAndIcon)
.font(.system(size: iconSize - 16))
} else {
Label(title, systemImage: systemImage)
.labelStyle(.iconOnly)
.font(.system(size: iconSize - 16))
}
} else {
// Original behaviour imageScale keeps existing look intact
if showLabels {
Label(title, systemImage: systemImage)
.labelStyle(.titleAndIcon)
.imageScale(scale)
} else {
Label(title, systemImage: systemImage)
.labelStyle(.iconOnly)
.imageScale(scale)
}
}
}
} }
#Preview { #Preview {
+22 -20
View File
@@ -30,15 +30,22 @@ struct FooterView: View {
let conversationName: String? let conversationName: String?
let hasUnsavedChanges: Bool let hasUnsavedChanges: Bool
let onQuickSave: (() -> Void)? let onQuickSave: (() -> Void)?
let onlineMode: Bool
let mcpEnabled: Bool
private let settings = SettingsService.shared
init(stats: SessionStats, init(stats: SessionStats,
conversationName: String? = nil, conversationName: String? = nil,
hasUnsavedChanges: Bool = false, hasUnsavedChanges: Bool = false,
onQuickSave: (() -> Void)? = nil) { onQuickSave: (() -> Void)? = nil,
onlineMode: Bool = false,
mcpEnabled: Bool = false) {
self.stats = stats self.stats = stats
self.conversationName = conversationName self.conversationName = conversationName
self.hasUnsavedChanges = hasUnsavedChanges self.hasUnsavedChanges = hasUnsavedChanges
self.onQuickSave = onQuickSave self.onQuickSave = onQuickSave
self.onlineMode = onlineMode
self.mcpEnabled = mcpEnabled
} }
var body: some View { var body: some View {
@@ -71,26 +78,26 @@ struct FooterView: View {
Spacer() Spacer()
// Save indicator (only when chat has messages) // Status pills Online, MCP, Sync
if stats.messageCount > 0 { #if os(macOS)
SaveIndicator( HStack(spacing: 6) {
conversationName: conversationName, if onlineMode {
hasUnsavedChanges: hasUnsavedChanges, StatusPill(icon: "globe", label: "Online", color: .green)
onSave: onQuickSave
)
} }
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
if settings.syncEnabled && settings.syncAutoSave {
SyncStatusPill()
}
}
#endif
// Update available badge // Update available badge (shows only when an update exists no version number)
#if os(macOS) #if os(macOS)
UpdateBadge() UpdateBadge()
#endif #endif
// Shortcuts hint
#if os(macOS)
Text("⌘N New • ⌘M Model • ⌘S Save")
.font(.caption2)
.foregroundColor(.oaiSecondary)
#endif
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -242,7 +249,6 @@ struct SyncStatusFooter: View {
struct UpdateBadge: View { struct UpdateBadge: View {
private let updater = UpdateCheckService.shared private let updater = UpdateCheckService.shared
private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
var body: some View { var body: some View {
if updater.updateAvailable { if updater.updateAvailable {
@@ -258,10 +264,6 @@ struct UpdateBadge: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("A new version is available — click to open the releases page") .help("A new version is available — click to open the releases page")
} else {
Text("v\(currentVersion)")
.font(.caption2)
.foregroundColor(.oaiSecondary)
} }
} }
} }
+71 -102
View File
@@ -2,7 +2,8 @@
// HeaderView.swift // HeaderView.swift
// oAI // oAI
// //
// Header bar with provider, model, and stats // Slim header provider, model name, star only.
// Status pills and stats live in SidebarView and FooterView respectively.
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen // Copyright (C) 2026 Rune Olsen
@@ -28,19 +29,68 @@ import SwiftUI
struct HeaderView: View { struct HeaderView: View {
let provider: Settings.Provider let provider: Settings.Provider
let model: ModelInfo? let model: ModelInfo?
let stats: SessionStats
let onlineMode: Bool
let mcpEnabled: Bool
let mcpStatus: String?
let onModelSelect: () -> Void let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void let onProviderChange: (Settings.Provider) -> Void
var conversationName: String? = nil
var hasUnsavedChanges: Bool = false
var onQuickSave: (() -> Void)? = nil
private let settings = SettingsService.shared private let settings = SettingsService.shared
private let registry = ProviderRegistry.shared private let registry = ProviderRegistry.shared
private let gitSync = GitSyncService.shared
var body: some View { var body: some View {
HStack(spacing: 20) { ZStack {
// Provider picker dropdown only shows configured providers // Left: provider + model + star
HStack(spacing: 12) {
providerMenu
modelButton
starButton
Spacer()
}
// Center: conversation title (macOS document-title style)
conversationTitle
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .bottom
)
}
// MARK: - Conversation title (center)
@ViewBuilder
private var conversationTitle: some View {
if let name = conversationName {
Button(action: { if hasUnsavedChanges { onQuickSave?() } }) {
HStack(spacing: 5) {
if hasUnsavedChanges {
Circle()
.fill(Color.orange)
.frame(width: 6, height: 6)
}
Text(name)
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
.foregroundColor(.oaiPrimary)
.lineLimit(1)
.frame(maxWidth: 300)
}
}
.buttonStyle(.plain)
.disabled(!hasUnsavedChanges)
.help(hasUnsavedChanges ? "Unsaved changes — click to save" : "Saved")
.animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges)
.animation(.easeInOut(duration: 0.2), value: name)
}
}
// MARK: - Subviews (extracted so ZStack stays readable)
private var providerMenu: some View {
Menu { Menu {
ForEach(registry.configuredProviders, id: \.self) { p in ForEach(registry.configuredProviders, id: \.self) { p in
Button { Button {
@@ -49,9 +99,7 @@ struct HeaderView: View {
HStack { HStack {
Image(systemName: p.iconName) Image(systemName: p.iconName)
Text(p.displayName) Text(p.displayName)
if p == provider { if p == provider { Image(systemName: "checkmark") }
Image(systemName: "checkmark")
}
} }
} }
} }
@@ -74,58 +122,46 @@ struct HeaderView: View {
.menuStyle(.borderlessButton) .menuStyle(.borderlessButton)
.fixedSize() .fixedSize()
.help("Switch provider") .help("Switch provider")
}
// Model info (clickable model selector) private var modelButton: some View {
Button(action: onModelSelect) { Button(action: onModelSelect) {
if let model = model { if let model = model {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(model.name) Text(model.name)
.font(.system(size: settings.guiTextSize, weight: .medium)) .font(.system(size: settings.guiTextSize, weight: .medium))
.foregroundColor(.oaiPrimary) .foregroundColor(.oaiPrimary)
// Capability badges
HStack(spacing: 3) { HStack(spacing: 3) {
if model.capabilities.vision { if model.capabilities.vision {
Image(systemName: "eye") Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
if model.capabilities.tools { if model.capabilities.tools {
Image(systemName: "wrench") Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
if model.capabilities.online { if model.capabilities.online {
Image(systemName: "globe") Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
if model.capabilities.imageGeneration { if model.capabilities.imageGeneration {
Image(systemName: "paintbrush") Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
} }
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
} }
} else { } else {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("No model selected") Text("No model selected")
.font(.system(size: settings.guiTextSize)) .font(.system(size: settings.guiTextSize))
.foregroundColor(.oaiSecondary) .foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
} }
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Select model") .help("Select model")
}
@ViewBuilder
private var starButton: some View {
if let model = model { if let model = model {
let isFav = settings.favoriteModelIds.contains(model.id) let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) { Button(action: { settings.toggleFavoriteModel(model.id) }) {
@@ -136,68 +172,10 @@ struct HeaderView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.help(isFav ? "Remove from favorites" : "Add to favorites") .help(isFav ? "Remove from favorites" : "Add to favorites")
} }
Spacer()
// Status indicators
HStack(spacing: 8) {
if model?.capabilities.imageGeneration == true {
StatusPill(icon: "paintbrush", label: "Image", color: .purple)
}
if onlineMode {
StatusPill(icon: "globe", label: "Online", color: .green)
}
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
if settings.syncEnabled && settings.syncAutoSave {
SyncStatusPill()
}
}
// Divider between status and stats
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) {
Divider()
.frame(height: 16)
.opacity(0.5)
}
// Quick stats
HStack(spacing: 16) {
StatItem(icon: "message", value: "\(stats.messageCount)")
StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay)
StatItem(icon: "dollarsign", value: stats.totalCostDisplay)
}
.font(.caption)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .bottom
)
} }
} }
struct StatItem: View { // MARK: - Status Pills (used by SidebarView)
let icon: String
let value: String
private let settings = SettingsService.shared
var body: some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: settings.guiTextSize - 3))
.foregroundColor(.oaiSecondary)
Text(value)
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
.foregroundColor(.oaiPrimary)
}
}
}
struct StatusPill: View { struct StatusPill: View {
let icon: String let icon: String
@@ -284,15 +262,6 @@ struct SyncStatusPill: View {
HeaderView( HeaderView(
provider: .openrouter, provider: .openrouter,
model: ModelInfo.mockModels.first, model: ModelInfo.mockModels.first,
stats: SessionStats(
totalInputTokens: 125,
totalOutputTokens: 434,
totalCost: 0.00111,
messageCount: 4
),
onlineMode: true,
mcpEnabled: true,
mcpStatus: "MCP",
onModelSelect: {}, onModelSelect: {},
onProviderChange: { _ in } onProviderChange: { _ in }
) )
+125 -130
View File
@@ -2,7 +2,7 @@
// InputBar.swift // InputBar.swift
// oAI // oAI
// //
// Message input bar with status indicators // Message input bar with resizable height and online toggle
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen // Copyright (C) 2026 Rune Olsen
@@ -24,19 +24,30 @@
import SwiftUI import SwiftUI
#if os(macOS)
import AppKit
#endif
struct InputBar: View { struct InputBar: View {
@Binding var text: String @Binding var text: String
let isGenerating: Bool let isGenerating: Bool
let mcpStatus: String?
let onlineMode: Bool let onlineMode: Bool
let onSend: () -> Void let onSend: () -> Void
let onCancel: () -> Void let onCancel: () -> Void
let onToggleOnline: () -> Void
private let settings = SettingsService.shared private let settings = SettingsService.shared
// Resizable input height persisted to settings
@State private var inputHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
@State private var dragStartHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
@State private var showCommandDropdown = false @State private var showCommandDropdown = false
@State private var selectedSuggestionIndex: Int = 0 @State private var selectedSuggestionIndex: Int = 0
@FocusState private var isInputFocused: Bool @State private var isInputFocused: Bool = false
private static let minInputHeight: CGFloat = 56
private static let maxInputHeight: CGFloat = 320
/// Commands that execute immediately without additional arguments /// Commands that execute immediately without additional arguments
private static let immediateCommands: Set<String> = [ private static let immediateCommands: Set<String> = [
@@ -56,110 +67,98 @@ struct InputBar: View {
CommandSuggestionsView( CommandSuggestionsView(
searchText: text, searchText: text,
selectedIndex: selectedSuggestionIndex, selectedIndex: selectedSuggestionIndex,
onSelect: { command in onSelect: selectCommand
selectCommand(command)
}
) )
.frame(width: 400) .frame(width: 400)
.frame(maxHeight: 200) .frame(maxHeight: 200)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
Spacer() Spacer()
} }
.padding(.leading, 96) // Align with input box (status badges + spacing) .padding(.leading, 16)
} }
// Input area // Drag-to-resize handle
dragHandle
// Input row
HStack(alignment: .bottom, spacing: 12) { HStack(alignment: .bottom, spacing: 12) {
// Status indicators // Text input with globe toggle in bottom-left corner
HStack(spacing: 6) {
if let mcp = mcpStatus {
StatusBadge(text: mcp, color: .blue)
}
if onlineMode {
StatusBadge(text: "🌐", color: .green)
}
}
.frame(width: 80, alignment: .leading)
// Text input
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
// Placeholder
if text.isEmpty { if text.isEmpty {
Text("Type a message or / for commands...") Text("Type a message or / for commands...")
.font(.system(size: settings.inputTextSize)) .font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiSecondary) .foregroundColor(.oaiSecondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.top, 10)
.allowsHitTesting(false)
} }
TextEditor(text: $text) // Editor fills the fixed-height box, bottom area reserved for globe
.font(.system(size: settings.inputTextSize)) NativeTextEditor(
.foregroundColor(.oaiPrimary) text: $text,
.scrollContentBackground(.hidden) font: .systemFont(ofSize: settings.inputTextSize),
.frame(minHeight: 44, maxHeight: 120) textColor: NSColor(Color.oaiPrimary),
.padding(.horizontal, 8) isFocused: isInputFocused,
.padding(.vertical, 6) onReturn: {
.focused($isInputFocused)
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
selectedSuggestionIndex = 0
}
#if os(macOS)
.onKeyPress(.upArrow) {
// Navigate command dropdown
if showCommandDropdown && selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
return .handled
}
return .ignored
}
.onKeyPress(.downArrow) {
// Navigate command dropdown
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
return .handled
}
}
return .ignored
}
.onKeyPress(.escape) {
// If command dropdown is showing, close it
if showCommandDropdown {
showCommandDropdown = false
return .handled
}
// If model is generating, cancel it
if isGenerating {
onCancel()
return .handled
}
return .ignored
}
.onKeyPress(.return, phases: .down) { press in
// Shift+Return: always insert newline (let system handle)
if press.modifiers.contains(.shift) {
return .ignored
}
// If command dropdown is showing, select the highlighted command
if showCommandDropdown { if showCommandDropdown {
let suggestions = CommandSuggestionsView.filteredCommands(for: text) let suggestions = CommandSuggestionsView.filteredCommands(for: text)
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count { if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
selectCommand(suggestions[selectedSuggestionIndex].command) selectCommand(suggestions[selectedSuggestionIndex].command)
return .handled return true
} }
} }
// Return (plain or with Cmd): send message if !text.isEmpty { onSend(); return true }
if !text.isEmpty { return true
onSend() },
return .handled onEscape: {
if showCommandDropdown { showCommandDropdown = false; return true }
if isGenerating { onCancel(); return true }
return false
},
onUpArrow: {
if showCommandDropdown && selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1; return true
} }
// Empty text: do nothing return false
return .handled },
onDownArrow: {
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1; return true
} }
#endif
} }
return false
},
onFocusChange: { focused in isInputFocused = focused }
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
selectedSuggestionIndex = 0
}
.padding(.bottom, 30)
// Online / offline toggle bottom-left of the text box
VStack {
Spacer()
HStack {
Button(action: onToggleOnline) {
Image(systemName: onlineMode ? "globe" : "network.slash")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(onlineMode ? Color.green : Color.secondary)
.padding(8)
}
.buttonStyle(.plain)
.help(onlineMode
? "Online mode on — click to go offline"
: "Offline — click to go online")
Spacer()
}
}
}
.frame(height: inputHeight)
.background(Color.oaiSurface) .background(Color.oaiSurface)
.cornerRadius(8) .cornerRadius(8)
.overlay( .overlay(
@@ -167,10 +166,9 @@ struct InputBar: View {
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1) .stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
) )
// Action buttons // Send / stop + attach buttons
VStack(spacing: 8) { VStack(spacing: 8) {
#if os(macOS) #if os(macOS)
// File attach button
Button(action: pickFile) { Button(action: pickFile) {
Image(systemName: "paperclip") Image(systemName: "paperclip")
.font(.title2) .font(.title2)
@@ -209,21 +207,47 @@ struct InputBar: View {
} }
} }
// MARK: - Drag handle
private var dragHandle: some View {
Color.clear
.frame(height: 8)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.overlay {
Capsule()
.fill(Color.secondary.opacity(0.25))
.frame(width: 36, height: 3)
}
.gesture(
DragGesture(minimumDistance: 1)
.onChanged { value in
let proposed = dragStartHeight - value.translation.height
inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed))
}
.onEnded { _ in
dragStartHeight = inputHeight
settings.inputBarHeight = Double(inputHeight)
}
)
#if os(macOS)
.onHover { hovering in
if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
}
#endif
}
// MARK: - Helpers
private func selectCommand(_ command: String) { private func selectCommand(_ command: String) {
showCommandDropdown = false showCommandDropdown = false
if Self.immediateCommands.contains(command) { if Self.immediateCommands.contains(command) {
// Execute immediately
text = command text = command
onSend() onSend()
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) { } else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
if shortcut.needsInput { text = shortcut.needsInput ? command + " " : command
text = command + " " if !shortcut.needsInput { onSend() }
} else { } else {
text = command
onSend()
}
} else {
// Put in input for user to complete
text = command + " " text = command + " "
} }
} }
@@ -235,36 +259,14 @@ struct InputBar: View {
panel.canChooseDirectories = false panel.canChooseDirectories = false
panel.canChooseFiles = true panel.canChooseFiles = true
panel.message = "Select files to attach" panel.message = "Select files to attach"
guard panel.runModal() == .OK else { return } guard panel.runModal() == .OK else { return }
let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ")
let paths = panel.urls.map { $0.path } text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText
// Use @<path> format (angle brackets) to safely handle paths with spaces
let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ")
if text.isEmpty {
text = attachmentText + " "
} else {
text += " " + attachmentText
}
} }
#endif #endif
} }
struct StatusBadge: View { // MARK: - Command suggestions
let text: String
let color: Color
var body: some View {
Text(text)
.font(.caption)
.foregroundColor(color)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.cornerRadius(4)
}
}
struct CommandSuggestionsView: View { struct CommandSuggestionsView: View {
let searchText: String let searchText: String
@@ -304,10 +306,9 @@ struct CommandSuggestionsView: View {
] ]
static func allCommands() -> [(command: String, description: LocalizedStringKey)] { static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
let shortcuts = SettingsService.shared.userShortcuts.map { s in SettingsService.shared.userShortcuts.map { s in
(s.command, LocalizedStringKey("\(s.description)")) (s.command, LocalizedStringKey("\(s.description)"))
} } + builtInCommands
return builtInCommands + shortcuts
} }
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] { static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
@@ -344,26 +345,20 @@ struct CommandSuggestionsView: View {
.id(suggestion.command) .id(suggestion.command)
if index < suggestions.count - 1 { if index < suggestions.count - 1 {
Divider() Divider().background(Color.oaiBorder)
.background(Color.oaiBorder)
} }
} }
} }
} }
.onChange(of: selectedIndex) { .onChange(of: selectedIndex) {
if selectedIndex < suggestions.count { if selectedIndex < suggestions.count {
withAnimation { withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) }
proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
}
} }
} }
} }
.background(Color.oaiSurface) .background(Color.oaiSurface)
.cornerRadius(8) .cornerRadius(8)
.overlay( .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1))
RoundedRectangle(cornerRadius: 8)
.stroke(Color.oaiBorder, lineWidth: 1)
)
} }
} }
@@ -373,10 +368,10 @@ struct CommandSuggestionsView: View {
InputBar( InputBar(
text: .constant(""), text: .constant(""),
isGenerating: false, isGenerating: false,
mcpStatus: "📁 Files",
onlineMode: true, onlineMode: true,
onSend: {}, onSend: {},
onCancel: {} onCancel: {},
onToggleOnline: {}
) )
} }
.background(Color.oaiBackground) .background(Color.oaiBackground)
+171
View File
@@ -0,0 +1,171 @@
//
// NativeTextEditor.swift
// oAI
//
// NSViewRepresentable text editor with correct Enter-key semantics:
// plain Enter send, Shift+Enter or Cmd+Enter newline.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import SwiftUI
import AppKit
struct NativeTextEditor: NSViewRepresentable {
@Binding var text: String
var font: NSFont
var textColor: NSColor
var isFocused: Bool
/// Plain Enter (no modifiers). Return true if the event was consumed.
var onReturn: () -> Bool
/// Escape key. Return true if consumed.
var onEscape: () -> Bool
/// Up arrow. Return true if consumed.
var onUpArrow: () -> Bool
/// Down arrow. Return true if consumed.
var onDownArrow: () -> Bool
/// Called when the view gains or loses first-responder status.
var onFocusChange: (Bool) -> Void
// MARK: - NSViewRepresentable
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = false
scrollView.hasHorizontalScroller = false
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
let tv = context.coordinator.textView
tv.delegate = context.coordinator
tv.isEditable = true
tv.isRichText = false
tv.drawsBackground = false
tv.backgroundColor = .clear
tv.isAutomaticQuoteSubstitutionEnabled = false
tv.isAutomaticDashSubstitutionEnabled = false
tv.isAutomaticSpellingCorrectionEnabled = true
tv.isContinuousSpellCheckingEnabled = true
tv.allowsUndo = true
tv.isVerticallyResizable = true
tv.isHorizontallyResizable = false
tv.autoresizingMask = [.width]
tv.textContainer?.widthTracksTextView = true
tv.textContainerInset = NSSize(width: 8, height: 6)
scrollView.documentView = tv
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
let tv = context.coordinator.textView
let coord = context.coordinator
// Update text only when it differs (avoids caret-jumping on every keystroke)
if tv.string != text {
let sel = tv.selectedRanges
tv.string = text
let len = (tv.string as NSString).length
tv.selectedRanges = sel.map { v in
let r = v.rangeValue
let loc = min(r.location, len)
let length = min(r.length, max(0, len - loc))
return NSValue(range: NSRange(location: loc, length: length))
}
}
if tv.font != font { tv.font = font }
if tv.textColor != textColor { tv.textColor = textColor }
// Keep coordinator callbacks current with each SwiftUI render
coord.textBinding = $text
coord.onReturn = onReturn
coord.onEscape = onEscape
coord.onUpArrow = onUpArrow
coord.onDownArrow = onDownArrow
coord.onFocusChange = onFocusChange
if isFocused {
DispatchQueue.main.async {
guard let window = tv.window, window.firstResponder !== tv else { return }
window.makeFirstResponder(tv)
}
}
}
func makeCoordinator() -> Coordinator { Coordinator() }
// MARK: - Coordinator
final class Coordinator: NSObject, NSTextViewDelegate {
let textView = KeyableNSTextView()
// Updated on every SwiftUI render via updateNSView
var textBinding: Binding<String>?
var onReturn: () -> Bool = { false }
var onEscape: () -> Bool = { false }
var onUpArrow: () -> Bool = { false }
var onDownArrow: () -> Bool = { false }
var onFocusChange: (Bool) -> Void = { _ in }
override init() {
super.init()
textView.coordinator = self
}
func textDidChange(_ notification: Notification) {
guard let tv = notification.object as? NSTextView else { return }
textBinding?.wrappedValue = tv.string
}
}
}
// MARK: - KeyableNSTextView
/// NSTextView that routes Return / Escape / arrow keys to the SwiftUI
/// coordinator before the AppKit default handling runs.
final class KeyableNSTextView: NSTextView {
weak var coordinator: NativeTextEditor.Coordinator?
override func keyDown(with event: NSEvent) {
guard let coord = coordinator else { super.keyDown(with: event); return }
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let shift = flags.contains(.shift)
let cmd = flags.contains(.command)
switch event.keyCode {
case 36: // Return
if shift || cmd {
// Shift+Enter or Cmd+Enter literal newline
insertNewlineIgnoringFieldEditor(nil)
} else {
// Plain Enter let SwiftUI decide (send or select dropdown item)
if !coord.onReturn() {
insertNewlineIgnoringFieldEditor(nil)
}
}
case 53: // Escape
if !coord.onEscape() { super.keyDown(with: event) }
case 126: // Up arrow
if !coord.onUpArrow() { super.keyDown(with: event) }
case 125: // Down arrow
if !coord.onDownArrow() { super.keyDown(with: event) }
default:
super.keyDown(with: event)
}
}
override func becomeFirstResponder() -> Bool {
let ok = super.becomeFirstResponder()
if ok { coordinator?.onFocusChange(true) }
return ok
}
override func resignFirstResponder() -> Bool {
let ok = super.resignFirstResponder()
if ok { coordinator?.onFocusChange(false) }
return ok
}
}
+216
View File
@@ -0,0 +1,216 @@
//
// SidebarView.swift
// oAI
//
// Collapsible sidebar: new chat, conversation list, status pills
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import SwiftUI
#if os(macOS)
import AppKit
#endif
struct SidebarView: View {
@Environment(ChatViewModel.self) private var chatViewModel
@State private var conversations: [Conversation] = []
@State private var searchText = ""
private var filteredConversations: [Conversation] {
guard !searchText.isEmpty else { return conversations }
return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) }
}
var body: some View {
VStack(spacing: 0) {
// New Chat button
Button(action: { chatViewModel.newConversation() }) {
HStack(spacing: 8) {
Image(systemName: "square.and.pencil")
.font(.system(size: 14))
Text("New Chat")
.font(.system(size: 14, weight: .medium))
Spacer()
}
.foregroundColor(.oaiPrimary)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Search field
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.system(size: 12))
.foregroundStyle(.secondary)
TextField("Search conversations…", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 13))
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
Divider().frame(height: 12)
Button {
chatViewModel.showConversations = true
} label: {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Advanced search — semantic search, bulk delete, export")
}
.padding(7)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
.padding(.horizontal, 8)
.padding(.bottom, 6)
Divider()
// Conversation list
if filteredConversations.isEmpty {
Spacer()
VStack(spacing: 8) {
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
.font(.title2)
.foregroundStyle(.tertiary)
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
.font(.callout)
.foregroundStyle(.secondary)
}
Spacer()
} else {
List {
ForEach(filteredConversations) { conversation in
SidebarConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
chatViewModel.loadConversation(conversation)
}
.listRowBackground(
chatViewModel.currentConversationName == conversation.name
? Color.oaiAccent.opacity(0.15)
: Color.clear
)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
deleteConversation(conversation)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
renameConversation(conversation)
} label: {
Label("Rename", systemImage: "pencil")
}
.tint(.orange)
}
}
}
.listStyle(.sidebar)
}
}
.onAppear { loadConversations() }
.onChange(of: chatViewModel.currentConversationName) { loadConversations() }
.onChange(of: chatViewModel.messages.count) { loadConversations() }
}
private func loadConversations() {
conversations = (try? DatabaseService.shared.listConversations()) ?? []
}
private func deleteConversation(_ conversation: Conversation) {
_ = try? DatabaseService.shared.deleteConversation(id: conversation.id)
withAnimation {
conversations.removeAll { $0.id == conversation.id }
}
}
private func renameConversation(_ conversation: Conversation) {
#if os(macOS)
let alert = NSAlert()
alert.messageText = "Rename Conversation"
alert.addButton(withTitle: "Rename")
alert.addButton(withTitle: "Cancel")
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
input.stringValue = conversation.name
input.selectText(nil)
alert.accessoryView = input
alert.window.initialFirstResponder = input
guard alert.runModal() == .alertFirstButtonReturn else { return }
let newName = input.stringValue.trimmingCharacters(in: .whitespaces)
guard !newName.isEmpty, newName != conversation.name else { return }
do {
_ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil)
if let i = conversations.firstIndex(where: { $0.id == conversation.id }) {
conversations[i].name = newName
conversations[i].updatedAt = Date()
}
chatViewModel.didRenameConversation(id: conversation.id, newName: newName)
} catch {
Log.db.error("Failed to rename conversation: \(error.localizedDescription)")
}
#endif
}
}
// MARK: - Sidebar conversation row
struct SidebarConversationRow: View {
let conversation: Conversation
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "dd.MM.yyyy"
return formatter.string(from: conversation.updatedAt)
}
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(conversation.name)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
HStack(spacing: 4) {
Text("^[\(conversation.messageCount) message](inflect: true)")
.font(.system(size: 11))
Text("·")
.font(.system(size: 11))
Text(formattedDate)
.font(.system(size: 11))
}
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
#Preview {
SidebarView()
.environment(ChatViewModel())
.frame(width: 240, height: 600)
}
@@ -0,0 +1,192 @@
//
// CombineConversationsSheet.swift
// oAI
//
// Combine 2+ saved conversations into one, optionally using AI to merge content
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import SwiftUI
struct CombineConversationsSheet: View {
@Environment(\.dismiss) var dismiss
let conversations: [Conversation]
var onCompleted: (Conversation) -> Void
@State private var name: String
@State private var mode: CombineMode = .simple
@State private var deleteOriginals = false
@State private var isProcessing = false
@State private var errorMessage: String?
private let settings = SettingsService.shared
init(conversations: [Conversation], onCompleted: @escaping (Conversation) -> Void) {
self.conversations = conversations
self.onCompleted = onCompleted
let joined = conversations.map(\.name).joined(separator: " + ")
_name = State(initialValue: String(joined.prefix(80)))
}
private var defaultModelLabel: String? {
guard let model = settings.defaultModel, !model.isEmpty else { return nil }
return "\(settings.defaultProvider.displayName) / \(model)"
}
private var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty
&& conversations.count >= 2
&& (mode == .simple || defaultModelLabel != nil)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Combine Conversations")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2).foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
.disabled(isProcessing)
}
.padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Combining \(conversations.count) conversations").font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 4) {
ForEach(conversations) { conversation in
Label("\(conversation.name) (\(conversation.messageCount) messages)", systemImage: "bubble.left.and.bubble.right")
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
}
}
VStack(alignment: .leading, spacing: 6) {
Text("New conversation name").font(.system(size: 13, weight: .semibold))
TextField("Name", text: $name)
.textFieldStyle(.roundedBorder)
.disabled(isProcessing)
}
VStack(alignment: .leading, spacing: 8) {
Text("Merge method").font(.system(size: 13, weight: .semibold))
Picker("", selection: $mode) {
Text("Simple Merge").tag(CombineMode.simple)
Text("AI-Assisted Merge").tag(CombineMode.ai)
}
.pickerStyle(.segmented)
.labelsHidden()
.disabled(isProcessing)
if mode == .simple {
Text("Messages from all selected conversations are combined in chronological order.")
.font(.caption).foregroundStyle(.secondary)
} else {
Text("A model reads all the source messages and rewrites them into one coherent, de-duplicated conversation.")
.font(.caption).foregroundStyle(.secondary)
if let label = defaultModelLabel {
Label("Uses your default model: \(label)", systemImage: "cpu")
.font(.caption).foregroundStyle(.secondary)
} else {
Label("No default model configured — set one in Settings → General.", systemImage: "exclamationmark.triangle.fill")
.font(.caption).foregroundStyle(.orange)
}
}
}
Toggle("Delete original conversations after combining", isOn: $deleteOriginals)
.toggleStyle(.checkbox)
.disabled(isProcessing)
if let errorMessage {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "xmark.octagon.fill").foregroundStyle(.red)
Text(errorMessage).font(.caption)
}
.padding(10)
.background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
}
}
.padding(.horizontal, 24).padding(.vertical, 16)
}
Divider()
HStack {
Button("Cancel") { dismiss() }
.buttonStyle(.bordered)
.disabled(isProcessing)
Spacer()
if isProcessing {
ProgressView().controlSize(.small)
Text("Combining…").font(.caption).foregroundStyle(.secondary)
}
Button("Combine") {
combine()
}
.buttonStyle(.borderedProminent)
.disabled(!isValid || isProcessing)
.keyboardShortcut(.return, modifiers: [.command])
}
.padding(.horizontal, 24).padding(.vertical, 12)
}
.frame(minWidth: 520, idealWidth: 560, minHeight: 460, idealHeight: 520)
}
private func combine() {
isProcessing = true
errorMessage = nil
let ids = conversations.map(\.id)
let trimmedName = name.trimmingCharacters(in: .whitespaces)
let selectedMode = mode
let shouldDeleteOriginals = deleteOriginals
Task {
do {
let newConversation = try await ConversationMergeService.merge(
conversationIds: ids,
name: trimmedName,
mode: selectedMode,
deleteOriginals: shouldDeleteOriginals
)
await MainActor.run {
isProcessing = false
onCompleted(newConversation)
dismiss()
}
} catch {
await MainActor.run {
isProcessing = false
errorMessage = error.localizedDescription
}
}
}
}
}
@@ -36,6 +36,7 @@ struct ConversationListView: View {
@State private var semanticResults: [Conversation] = [] @State private var semanticResults: [Conversation] = []
@State private var isSearching = false @State private var isSearching = false
@State private var selectedIndex: Int = 0 @State private var selectedIndex: Int = 0
@State private var showCombineSheet = false
@FocusState private var searchFocused: Bool @FocusState private var searchFocused: Bool
private let settings = SettingsService.shared private let settings = SettingsService.shared
var onLoad: ((Conversation) -> Void)? var onLoad: ((Conversation) -> Void)?
@@ -70,6 +71,18 @@ struct ConversationListView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
if selectedConversations.count >= 2 {
Button {
showCombineSheet = true
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.merge")
Text("Combine (\(selectedConversations.count))")
}
}
.buttonStyle(.plain)
}
if !selectedConversations.isEmpty { if !selectedConversations.isEmpty {
Button(role: .destructive) { Button(role: .destructive) {
deleteSelected() deleteSelected()
@@ -298,6 +311,16 @@ struct ConversationListView: View {
searchFocused = true searchFocused = true
} }
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600) .frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
.sheet(isPresented: $showCombineSheet) {
CombineConversationsSheet(
conversations: conversations.filter { selectedConversations.contains($0.id) },
onCompleted: { _ in
loadConversations()
selectedConversations.removeAll()
isSelecting = false
}
)
}
} }
private func loadConversations() { private func loadConversations() {
+12 -1
View File
@@ -30,6 +30,7 @@ struct ModelInfoView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Bindable private var settings = SettingsService.shared @Bindable private var settings = SettingsService.shared
@State private var isDescriptionExpanded = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -78,8 +79,18 @@ struct ModelInfoView: View {
Text(desc) Text(desc)
.font(.body) .font(.body)
.foregroundColor(.primary) .foregroundColor(.primary)
.fixedSize(horizontal: false, vertical: true) .lineLimit(isDescriptionExpanded ? nil : 4)
.textSelection(.enabled) .textSelection(.enabled)
if desc.count > 250 {
Button(isDescriptionExpanded ? "Less" : "More…") {
withAnimation(.easeInOut(duration: 0.2)) {
isDescriptionExpanded.toggle()
}
}
.font(.callout)
.foregroundStyle(.blue)
.buttonStyle(.plain)
}
} }
.padding(.leading, 4) .padding(.leading, 4)
} }
+36
View File
@@ -92,6 +92,8 @@ struct oAIApp: App {
CommandGroup(after: .newItem) { CommandGroup(after: .newItem) {
Button("Open Chat…") { chatViewModel.showConversations = true } Button("Open Chat…") { chatViewModel.showConversations = true }
.keyboardShortcut("o", modifiers: .command) .keyboardShortcut("o", modifiers: .command)
Button("Search Conversations") { chatViewModel.showConversations = true }
.keyboardShortcut("l", modifiers: .command)
} }
CommandGroup(replacing: .saveItem) { CommandGroup(replacing: .saveItem) {
@@ -113,10 +115,44 @@ struct oAIApp: App {
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
} }
// View menu
CommandMenu("View") {
Button("Select Model") { chatViewModel.showModelSelector = true }
.keyboardShortcut("m", modifiers: .command)
Button("Model Info") {
chatViewModel.modelInfoTarget = chatViewModel.selectedModel
}
.keyboardShortcut("i", modifiers: .command)
.disabled(chatViewModel.selectedModel == nil)
Divider()
Button("Command History") { chatViewModel.showHistory = true }
.keyboardShortcut("h", modifiers: .command)
Button("In-App Help") { chatViewModel.showHelp = true }
.keyboardShortcut("/", modifiers: .command)
Button("Credits") { chatViewModel.showCredits = true }
Divider()
Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") {
chatViewModel.onlineMode.toggle()
}
.keyboardShortcut("o", modifiers: [.command, .shift])
}
// Help menu // Help menu
CommandGroup(replacing: .help) { CommandGroup(replacing: .help) {
Button("oAI Help") { openHelp() } Button("oAI Help") { openHelp() }
.keyboardShortcut("?", modifiers: .command) .keyboardShortcut("?", modifiers: .command)
Divider()
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
UpdateCheckService.shared.checkForUpdatesManually()
}
.disabled(UpdateCheckService.shared.isCheckingManually)
} }
} }
#endif #endif