Compare commits
17 Commits
cd0ceeab41
...
2.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c9054bd5 | |||
| 20121981a0 | |||
| e8db4ad7a3 | |||
| 5b99a6f81c | |||
| a793fdacc4 | |||
| 414cf8cb8c | |||
| e7c7b9b5c6 | |||
| 87535dc2ad | |||
| 3dff8a8c8e | |||
| 00dccd648c | |||
| 92e393ab03 | |||
| 22f745762f | |||
| b3bb7c4a59 | |||
| ef1c05c13b | |||
| f63226b2cc | |||
| f3a0c45331 | |||
| 8451db1142 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ A powerful native macOS AI chat application with support for multiple providers,
|
|||||||
|
|
||||||
### 🤖 Multi-Provider Support
|
### 🤖 Multi-Provider Support
|
||||||
- **OpenAI** - GPT models with native API support
|
- **OpenAI** - GPT models with native API support
|
||||||
- **Anthropic** - All Claude models
|
- **Anthropic** - All Claude models; prompt caching support (direct API and via OpenRouter) reduces cost on repeated system prompts/context
|
||||||
- **OpenRouter** - Access to 300+ AI models from multiple providers
|
- **OpenRouter** - Access to 300+ AI models from multiple providers
|
||||||
- **Ollama** - Local model inference for privacy
|
- **Ollama** - Local model inference for privacy
|
||||||
|
|
||||||
### 💬 Core Chat Capabilities
|
### 💬 Core Chat Capabilities
|
||||||
- **Streaming Responses** - Real-time token streaming for faster interactions
|
- **Streaming Responses** - Real-time token streaming for faster interactions
|
||||||
- **Conversation Management** - Save, load, export, and search conversations
|
- **Conversation Management** - Save, load, export, and search conversations
|
||||||
|
- **Combine Conversations** - Merge 2+ saved conversations, either by chronological concatenation or AI-assisted synthesis
|
||||||
- **File Attachments** - Support for text files, images, and PDFs
|
- **File Attachments** - Support for text files, images, and PDFs
|
||||||
- **Image Generation** - Create images with supported models (DALL-E, Flux, etc.) — renders inline in chat
|
- **Image Generation** - Create images with supported models (DALL-E, Flux, etc.) - renders inline in chat
|
||||||
- **Reasoning / Thinking Tokens** - Stream live reasoning from thinking-capable models (DeepSeek R1, Claude 3.7+, o1/o3, Qwen); configurable effort level (High/Medium/Low/Minimal); collapsible block auto-expands while thinking and collapses when the answer arrives
|
- **Reasoning / Thinking Tokens** - Stream live reasoning from thinking-capable models (DeepSeek R1, Claude 3.7+, o1/o3, Qwen); configurable effort level (High/Medium/Low/Minimal); collapsible block auto-expands while thinking and collapses when the answer arrives
|
||||||
- **Online Mode** - DuckDuckGo and Google web search integration
|
- **Online Mode** - DuckDuckGo and Google web search integration
|
||||||
- **Session Statistics** - Track token usage, costs, and response times
|
- **Session Statistics** - Track token usage, costs, and response times
|
||||||
@@ -51,17 +52,25 @@ Seamless conversation backup and sync across devices:
|
|||||||
|
|
||||||
### ⚡ Shortcuts & Agent Skills
|
### ⚡ Shortcuts & Agent Skills
|
||||||
- **Shortcuts** - Personal slash commands that expand to prompt templates; optional `{{input}}` placeholder for inline input
|
- **Shortcuts** - Personal slash commands that expand to prompt templates; optional `{{input}}` placeholder for inline input
|
||||||
- **Agent Skills (SKILL.md)** - Markdown instruction files injected into the system prompt; compatible with skill0.io, skillsmp.com, and other SKILL.md marketplaces; import as `.md` or `.zip` bundle with attached data files
|
- **Agent Skills (SKILL.md)** - Markdown instruction files injected into the system prompt; compatible with skill0.io, skillsmp.com, and other SKILL.md marketplaces; import as `.md` or `.zip` bundle with attached data files; a skill named exactly "2nd Brain" can be marked always-trusted, skipping the bash approval prompt for its helper-script calls
|
||||||
|
|
||||||
### 📚 Anytype Integration
|
### 📚 Anytype Integration
|
||||||
Connect oAI to your local [Anytype](https://anytype.io) knowledge base:
|
Connect oAI to your local [Anytype](https://anytype.io) knowledge base:
|
||||||
- **Search** — find objects by keyword across all spaces or within a specific one
|
- **Search** - find objects by keyword across all spaces or within a specific one
|
||||||
- **Read** — open any object and read its full markdown content
|
- **Read** - open any object and read its full markdown content
|
||||||
- **Append** — add content to the end of an existing object without touching existing text or internal links (preferred over full update)
|
- **Append** - add content to the end of an existing object without touching existing text or internal links (preferred over full update)
|
||||||
- **Create** — make new notes, tasks, or pages
|
- **Create** - make new notes, tasks, or pages
|
||||||
- **Checkbox tools** — surgically toggle to-do checkboxes or set task done/undone via native relation
|
- **Checkbox tools** - surgically toggle to-do checkboxes or set task done/undone via native relation
|
||||||
- All data stays on your machine (local API, no cloud)
|
- All data stays on your machine (local API, no cloud)
|
||||||
|
|
||||||
|
### 🛰️ Jarvis Integration
|
||||||
|
Connect oAI to a self-hosted [Jarvis](https://jarvis.pm) agent-automation server:
|
||||||
|
- **Agent Management** - List, create, edit, enable/disable, run, and stop agents
|
||||||
|
- **Run History** - Expandable per-run output with status and timing
|
||||||
|
- **Usage & Credits** - Per-agent usage stats and credits balance
|
||||||
|
- **Queue Control** - Pause/resume all agents
|
||||||
|
- `/jarvis` slash command opens the Jarvis panel directly
|
||||||
|
|
||||||
### 🖥️ Power-User Features
|
### 🖥️ Power-User Features
|
||||||
- **Bash Execution** - AI can run shell commands via `/bin/zsh` (opt-in, with per-command approval prompt)
|
- **Bash Execution** - AI can run shell commands via `/bin/zsh` (opt-in, with per-command approval prompt)
|
||||||
- **iCloud Backup** - One-click settings backup to iCloud Drive; restore on any Mac; API keys excluded for security
|
- **iCloud Backup** - One-click settings backup to iCloud Drive; restore on any Mac; API keys excluded for security
|
||||||
@@ -85,9 +94,10 @@ Automated email responses powered by AI:
|
|||||||
- Footer stats display (messages, tokens, cost, sync status)
|
- Footer stats display (messages, tokens, cost, sync status)
|
||||||
- Header status indicators (MCP, Online mode, Git sync)
|
- Header status indicators (MCP, Online mode, Git sync)
|
||||||
- Responsive message layout with copy buttons
|
- Responsive message layout with copy buttons
|
||||||
- **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠), sort by price or context window, search by name or description, per-row ⓘ info button; ★ favourite any model — favourites float to the top and can be filtered in one click
|
- **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠) or by category (Programming, Math, Medical, Translation, Roleplay, Creative, Science, Finance, Legal), sort by price or context window, search by name or description, per-row ⓘ info button; ★ favourite any model - favourites float to the top and can be filtered in one click
|
||||||
- **Default Model** - Set a fixed startup model in Settings → General; switching models during a session does not overwrite it
|
- **Default Model** - Set a fixed startup model in Settings → General; switching models during a session does not overwrite it
|
||||||
- **Localization** - UI ~~fully translated~~ being translated into Norwegian Bokmål, Swedish, Danish, and German; follows macOS language preference automatically
|
- **Sidebar Navigation** - Collapsible sidebar for switching between conversations
|
||||||
|
- **Localization** - Fully localized into Norwegian Bokmål, Swedish, Danish, German, and French; follows macOS language preference automatically. Translations are AI-generated (machine translation), not reviewed by native speakers - if you spot an awkward or incorrect phrase, please [open an issue](https://gitlab.pm/rune/oai-swift/issues/new)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -97,8 +107,8 @@ Automated email responses powered by AI:
|
|||||||
|
|
||||||
Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-swift/releases). Two builds are available:
|
Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-swift/releases). Two builds are available:
|
||||||
|
|
||||||
- **oAI-x.x.x-AppleSilicon.dmg** — for Macs with an Apple Silicon chip (M1 and later)
|
- **oAI-x.x.x-AppleSilicon.dmg** - for Macs with an Apple Silicon chip (M1 and later)
|
||||||
- **oAI-x.x.x-Universal.dmg** — runs natively on both Apple Silicon and Intel Macs
|
- **oAI-x.x.x-Universal.dmg** - runs natively on both Apple Silicon and Intel Macs
|
||||||
|
|
||||||
### Installing from DMG
|
### Installing from DMG
|
||||||
|
|
||||||
@@ -107,19 +117,19 @@ Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-
|
|||||||
3. Eject the DMG
|
3. Eject the DMG
|
||||||
4. Launch oAI from Applications or Spotlight
|
4. Launch oAI from Applications or Spotlight
|
||||||
|
|
||||||
### First Launch — Gatekeeper Warning
|
### First Launch - Gatekeeper Warning
|
||||||
|
|
||||||
oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan — the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified."
|
oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan - the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified."
|
||||||
|
|
||||||
To open the app, you have two options:
|
To open the app, you have two options:
|
||||||
|
|
||||||
**Option A — Right-click to open (quickest):**
|
**Option A - Right-click to open (quickest):**
|
||||||
1. Right-click (or Control-click) `oAI.app` in Applications
|
1. Right-click (or Control-click) `oAI.app` in Applications
|
||||||
2. Select **Open** from the context menu
|
2. Select **Open** from the context menu
|
||||||
3. Click **Open** in the dialog that appears
|
3. Click **Open** in the dialog that appears
|
||||||
4. After doing this once, the app opens normally from then on
|
4. After doing this once, the app opens normally from then on
|
||||||
|
|
||||||
**Option B — Remove the quarantine flag via Terminal:**
|
**Option B - Remove the quarantine flag via Terminal:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xattr -dr com.apple.quarantine /Applications/oAI.app
|
xattr -dr com.apple.quarantine /Applications/oAI.app
|
||||||
@@ -139,7 +149,7 @@ Add your API keys in Settings (⌘,) → General tab:
|
|||||||
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth
|
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth
|
||||||
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
|
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
|
||||||
- **Ollama** - Base URL (default: http://localhost:11434)
|
- **Ollama** - Base URL (default: http://localhost:11434)
|
||||||
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) — not a chat provider
|
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) - not a chat provider
|
||||||
|
|
||||||
### Essential Settings
|
### Essential Settings
|
||||||
|
|
||||||
@@ -313,12 +323,16 @@ AI-powered email auto-responder:
|
|||||||
|
|
||||||
- [x] Vector index for faster semantic search (sqlite-vss)
|
- [x] Vector index for faster semantic search (sqlite-vss)
|
||||||
- [x] Reasoning / thinking tokens (streamed live, collapsible)
|
- [x] Reasoning / thinking tokens (streamed live, collapsible)
|
||||||
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German)
|
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German, French)
|
||||||
- [x] iCloud Backup (settings export/restore)
|
- [x] iCloud Backup (settings export/restore)
|
||||||
- [x] Bash execution with per-command approval
|
- [x] Bash execution with per-command approval
|
||||||
- [x] Anytype integration (read, append, create, checkbox tools)
|
- [x] Anytype integration (read, append, create, checkbox tools)
|
||||||
- [x] Model favourites (starred models, filter, float to top)
|
- [x] Model favourites (starred models, filter, float to top)
|
||||||
- [ ] SOUL.md / USER.md — living identity documents injected into system prompt
|
- [x] Jarvis integration (agent management, run history, usage/credits)
|
||||||
|
- [x] Model category filter (Programming, Math, Medical, etc.)
|
||||||
|
- [x] Combine saved conversations (concatenation or AI-assisted synthesis)
|
||||||
|
- [x] Sidebar navigation redesign
|
||||||
|
- [ ] SOUL.md / USER.md - living identity documents injected into system prompt
|
||||||
- [ ] Parallel research agents (read-only, concurrent)
|
- [ ] Parallel research agents (read-only, concurrent)
|
||||||
- [ ] Local embeddings (sentence-transformers, $0 cost)
|
- [ ] Local embeddings (sentence-transformers, $0 cost)
|
||||||
- [ ] Multi-modal conversation export (PDF, HTML)
|
- [ ] Multi-modal conversation export (PDF, HTML)
|
||||||
@@ -332,9 +346,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,15 +355,12 @@ 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
oAI takes real actions on your behalf — it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind — the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
|
oAI takes real actions on your behalf - it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind - the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
da,
|
da,
|
||||||
de,
|
de,
|
||||||
sv,
|
sv,
|
||||||
|
fr,
|
||||||
);
|
);
|
||||||
mainGroup = A550A6592F3B72EA00136F2B;
|
mainGroup = A550A6592F3B72EA00136F2B;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
@@ -279,11 +280,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 +324,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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] = [],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ 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
|
||||||
let content: MessageContent
|
let content: MessageContent
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
private var isIntelNative: Bool {
|
||||||
// New conversation
|
var systemInfo = utsname()
|
||||||
Button(action: { chatViewModel.newConversation() }) {
|
uname(&systemInfo)
|
||||||
ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, iconSize: iconSize)
|
let machine = withUnsafeBytes(of: &systemInfo.machine) {
|
||||||
}
|
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
|
||||||
.help("New conversation")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showConversations = true }) {
|
|
||||||
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("l", modifiers: .command)
|
|
||||||
.help("Saved conversations (Cmd+L)")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showHistory = true }) {
|
|
||||||
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("h", modifiers: .command)
|
|
||||||
.help("Command history (Cmd+H)")
|
|
||||||
|
|
||||||
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+/)")
|
|
||||||
}
|
}
|
||||||
|
return machine.contains("x86_64")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isRosetta: Bool {
|
||||||
|
var ret: Int32 = 0
|
||||||
|
var size = MemoryLayout<Int32>.size
|
||||||
|
sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
|
||||||
|
return ret == 1
|
||||||
}
|
}
|
||||||
#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 34→18pt, 36→20pt, 38→22pt, 40→24pt
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-157
@@ -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,150 +29,29 @@ 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
|
||||||
Menu {
|
HStack(spacing: 12) {
|
||||||
ForEach(registry.configuredProviders, id: \.self) { p in
|
providerMenu
|
||||||
Button {
|
modelButton
|
||||||
onProviderChange(p)
|
starButton
|
||||||
} label: {
|
Spacer()
|
||||||
HStack {
|
|
||||||
Image(systemName: p.iconName)
|
|
||||||
Text(p.displayName)
|
|
||||||
if p == provider {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: provider.iconName)
|
|
||||||
.font(.system(size: settings.guiTextSize - 2))
|
|
||||||
Text(provider.displayName)
|
|
||||||
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
|
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
|
||||||
.font(.system(size: 8))
|
|
||||||
.opacity(0.7)
|
|
||||||
}
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.providerColor(provider))
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
.menuStyle(.borderlessButton)
|
|
||||||
.fixedSize()
|
|
||||||
.help("Switch provider")
|
|
||||||
|
|
||||||
// Model info (clickable → model selector)
|
|
||||||
Button(action: onModelSelect) {
|
|
||||||
if let model = model {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text(model.name)
|
|
||||||
.font(.system(size: settings.guiTextSize, weight: .medium))
|
|
||||||
.foregroundColor(.oaiPrimary)
|
|
||||||
|
|
||||||
// Capability badges
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
if model.capabilities.vision {
|
|
||||||
Image(systemName: "eye")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
if model.capabilities.tools {
|
|
||||||
Image(systemName: "wrench")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
if model.capabilities.online {
|
|
||||||
Image(systemName: "globe")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
if model.capabilities.imageGeneration {
|
|
||||||
Image(systemName: "paintbrush")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("No model selected")
|
|
||||||
.font(.system(size: settings.guiTextSize))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help("Select model")
|
|
||||||
|
|
||||||
if let model = model {
|
|
||||||
let isFav = settings.favoriteModelIds.contains(model.id)
|
|
||||||
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
|
||||||
Image(systemName: isFav ? "star.fill" : "star")
|
|
||||||
.font(.system(size: settings.guiTextSize - 3))
|
|
||||||
.foregroundColor(isFav ? .yellow : .oaiSecondary)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
// Center: conversation title (macOS document-title style)
|
||||||
|
conversationTitle
|
||||||
// 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(.horizontal, 16)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
.background(.ultraThinMaterial)
|
.background(.ultraThinMaterial)
|
||||||
.overlay(
|
.overlay(
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@@ -180,25 +60,123 @@ struct HeaderView: View {
|
|||||||
alignment: .bottom
|
alignment: .bottom
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
struct StatItem: View {
|
// MARK: - Conversation title (center)
|
||||||
let icon: String
|
|
||||||
let value: String
|
|
||||||
private let settings = SettingsService.shared
|
|
||||||
|
|
||||||
var body: some View {
|
@ViewBuilder
|
||||||
HStack(spacing: 4) {
|
private var conversationTitle: some View {
|
||||||
Image(systemName: icon)
|
if let name = conversationName {
|
||||||
.font(.system(size: settings.guiTextSize - 3))
|
Button(action: { if hasUnsavedChanges { onQuickSave?() } }) {
|
||||||
.foregroundColor(.oaiSecondary)
|
HStack(spacing: 5) {
|
||||||
Text(value)
|
if hasUnsavedChanges {
|
||||||
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
|
Circle()
|
||||||
.foregroundColor(.oaiPrimary)
|
.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 {
|
||||||
|
ForEach(registry.configuredProviders, id: \.self) { p in
|
||||||
|
Button {
|
||||||
|
onProviderChange(p)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: p.iconName)
|
||||||
|
Text(p.displayName)
|
||||||
|
if p == provider { Image(systemName: "checkmark") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: provider.iconName)
|
||||||
|
.font(.system(size: settings.guiTextSize - 2))
|
||||||
|
Text(provider.displayName)
|
||||||
|
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.opacity(0.7)
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.providerColor(provider))
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.fixedSize()
|
||||||
|
.help("Switch provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelButton: some View {
|
||||||
|
Button(action: onModelSelect) {
|
||||||
|
if let model = model {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(model.name)
|
||||||
|
.font(.system(size: settings.guiTextSize, weight: .medium))
|
||||||
|
.foregroundColor(.oaiPrimary)
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
if model.capabilities.vision {
|
||||||
|
Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
if model.capabilities.tools {
|
||||||
|
Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
if model.capabilities.online {
|
||||||
|
Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
if model.capabilities.imageGeneration {
|
||||||
|
Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("No model selected")
|
||||||
|
.font(.system(size: settings.guiTextSize))
|
||||||
|
.foregroundColor(.oaiSecondary)
|
||||||
|
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Select model")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var starButton: some View {
|
||||||
|
if let model = model {
|
||||||
|
let isFav = settings.favoriteModelIds.contains(model.id)
|
||||||
|
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
||||||
|
Image(systemName: isFav ? "star.fill" : "star")
|
||||||
|
.font(.system(size: settings.guiTextSize - 3))
|
||||||
|
.foregroundColor(isFav ? .yellow : .oaiSecondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Status Pills (used by SidebarView)
|
||||||
|
|
||||||
struct StatusPill: View {
|
struct StatusPill: View {
|
||||||
let icon: String
|
let icon: String
|
||||||
let label: LocalizedStringKey
|
let label: LocalizedStringKey
|
||||||
@@ -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 }
|
||||||
)
|
)
|
||||||
|
|||||||
+126
-131
@@ -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,121 +67,108 @@ 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
|
||||||
HStack(alignment: .bottom, spacing: 12) {
|
dragHandle
|
||||||
// Status indicators
|
|
||||||
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
|
// Input row
|
||||||
|
HStack(alignment: .bottom, spacing: 12) {
|
||||||
|
// Text input with globe toggle in bottom-left corner
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
#endif
|
}
|
||||||
}
|
}
|
||||||
|
.frame(height: inputHeight)
|
||||||
.background(Color.oaiSurface)
|
.background(Color.oaiSurface)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 8)
|
||||||
.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 {
|
|
||||||
text = command
|
|
||||||
onSend()
|
|
||||||
}
|
|
||||||
} else {
|
} 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user