Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b99a6f81c | |||
| a793fdacc4 | |||
| 414cf8cb8c | |||
| e7c7b9b5c6 | |||
| 87535dc2ad | |||
| 3dff8a8c8e | |||
| 00dccd648c | |||
| 92e393ab03 | |||
| 22f745762f | |||
| b3bb7c4a59 | |||
| ef1c05c13b | |||
| f63226b2cc | |||
| f3a0c45331 | |||
| 8451db1142 | |||
| cd0ceeab41 | |||
| 13699864d8 | |||
| c2010e272e | |||
| 098c3c3d1e | |||
| 3d6ac578db | |||
| 0f9dc05774 | |||
| 3f9b30bfa1 | |||
| 375b8fb345 | |||
| c5c2667553 | |||
| f375b1172b |
@@ -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
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ Seamless conversation backup and sync across devices:
|
|||||||
- **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
|
||||||
|
|
||||||
|
### 📚 Anytype Integration
|
||||||
|
Connect oAI to your local [Anytype](https://anytype.io) knowledge base:
|
||||||
|
- **Search** — find objects by keyword across all spaces or within a specific one
|
||||||
|
- **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)
|
||||||
|
- **Create** — make new notes, tasks, or pages
|
||||||
|
- **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)
|
||||||
|
|
||||||
### 🖥️ 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
|
||||||
@@ -76,8 +85,9 @@ 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
|
- **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
|
||||||
- **Localization** - UI fully translated into Norwegian Bokmål, Swedish, Danish, and German; follows macOS language preference automatically
|
- **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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -306,6 +316,8 @@ AI-powered email auto-responder:
|
|||||||
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German)
|
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German)
|
||||||
- [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] Model favourites (starred models, filter, float to top)
|
||||||
- [ ] SOUL.md / USER.md — living identity documents injected into system prompt
|
- [ ] 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)
|
||||||
@@ -320,9 +332,6 @@ This means you are free to use, study, modify, and distribute oAI, but any modif
|
|||||||
|
|
||||||
See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html).
|
See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, database schema, and contribution guidelines.
|
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
@@ -332,9 +341,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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -279,11 +279,11 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 2.3.7;
|
MARKETING_VERSION = 2.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -323,11 +323,11 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 2.3.7;
|
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>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"strings" : {
|
||||||
|
"·" : {
|
||||||
|
"comment" : "A separator between the message count and the date.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"· %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
"(always used)" : {
|
"(always used)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -432,6 +439,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"^[%@ message](inflect: true)" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"^[%@ token](inflect: true)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"© 2026 [Rune Olsen](https://blog.rune.pm)" : {
|
"© 2026 [Rune Olsen](https://blog.rune.pm)" : {
|
||||||
"comment" : "A copyright notice with the copyright holder's name.",
|
"comment" : "A copyright notice with the copyright holder's name.",
|
||||||
@@ -521,6 +534,7 @@
|
|||||||
},
|
},
|
||||||
"⌘N New • ⌘M Model • ⌘S Save" : {
|
"⌘N New • ⌘M Model • ⌘S Save" : {
|
||||||
"comment" : "A hint that appears on macOS when using keyboard shortcuts.",
|
"comment" : "A hint that appears on macOS when using keyboard shortcuts.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -549,6 +563,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly." : {
|
||||||
|
"comment" : "A warning displayed in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"⚠️ Custom prompt active — only this prompt will be sent to the model." : {
|
"⚠️ Custom prompt active — only this prompt will be sent to the model." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -894,6 +912,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"🧠" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"1. Open Anytype → Settings → Integrations" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"1. Open Paperless-NGX → Settings → API Tokens" : {
|
"1. Open Paperless-NGX → Settings → API Tokens" : {
|
||||||
"comment" : "A step in the process of getting a Paperless-NGX API token.",
|
"comment" : "A step in the process of getting a Paperless-NGX API token.",
|
||||||
@@ -925,6 +949,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"2. Create a new API key" : {
|
||||||
|
"comment" : "A step in the process of getting an API key from Anytype.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"2. Create or copy your token" : {
|
"2. Create or copy your token" : {
|
||||||
"comment" : "A step in the process of getting a Paperless-NGX API token.",
|
"comment" : "A step in the process of getting a Paperless-NGX API token.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@@ -1132,6 +1160,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Agent" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Agent Skills" : {
|
"Agent Skills" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1160,6 +1191,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Agents" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Allow Shell Command?" : {
|
"Allow Shell Command?" : {
|
||||||
"comment" : "A title for a modal that asks the user if they want to allow a shell command.",
|
"comment" : "A title for a modal that asks the user if they want to allow a shell command.",
|
||||||
@@ -1566,6 +1600,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Category" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Changing these values affects how the AI generates responses. The defaults work well for most use cases." : {
|
"Changing these values affects how the AI generates responses. The defaults work well for most use cases." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1654,6 +1691,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Choose an agent from the list to view details and run history" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Clear All" : {
|
"Clear All" : {
|
||||||
"comment" : "A button to clear all email activity logs.",
|
"comment" : "A button to clear all email activity logs.",
|
||||||
@@ -1918,6 +1958,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Cost" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Cost Examples" : {
|
"Cost Examples" : {
|
||||||
"comment" : "A heading for the cost examples of a model.",
|
"comment" : "A heading for the cost examples of a model.",
|
||||||
@@ -2092,6 +2135,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Disabled" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Each command will require your approval before running." : {
|
"Each command will require your approval before running." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2468,6 +2514,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Filter by Category" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Generate an API key in your Jarvis settings and paste it above." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Google (Gemini embedding)" : {
|
"Google (Gemini embedding)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2526,6 +2578,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"High (~80%)" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"How to get your API key:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"How to get your API token:" : {
|
"How to get your API token:" : {
|
||||||
"comment" : "A heading for a section that describes how to get your API token.",
|
"comment" : "A heading for a section that describes how to get your API token.",
|
||||||
@@ -2640,6 +2698,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Input" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Large files inflate the system prompt and may hit token limits." : {
|
"Large files inflate the system prompt and may hit token limits." : {
|
||||||
"comment" : "A warning displayed when a user adds a large file to a skill.",
|
"comment" : "A warning displayed when a user adds a large file to a skill.",
|
||||||
@@ -2726,6 +2787,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Low (~20%)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Lowercase letters, numbers, and hyphens only. No spaces." : {
|
"Lowercase letters, numbers, and hyphens only. No spaces." : {
|
||||||
"comment" : "A description of the format of a shortcut's command.",
|
"comment" : "A description of the format of a shortcut's command.",
|
||||||
@@ -2842,6 +2906,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Medium (~50%)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"messages" : {
|
"messages" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2870,6 +2937,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Minimal (~10%)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Model Context Protocol" : {
|
"Model Context Protocol" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2928,6 +2998,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Model thinks internally but reasoning is not shown in chat" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Multi-provider AI chat client" : {
|
"Multi-provider AI chat client" : {
|
||||||
"comment" : "A description of oAI.",
|
"comment" : "A description of oAI.",
|
||||||
@@ -3018,6 +3091,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"New Chat" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No credit data available" : {
|
"No credit data available" : {
|
||||||
"comment" : "A message displayed when there is no credit data available.",
|
"comment" : "A message displayed when there is no credit data available.",
|
||||||
@@ -3196,6 +3272,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"No runs yet" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No shortcuts yet" : {
|
"No shortcuts yet" : {
|
||||||
"comment" : "A message displayed when a user has no shortcuts.",
|
"comment" : "A message displayed when a user has no shortcuts.",
|
||||||
@@ -3347,6 +3426,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates." : {
|
||||||
|
"comment" : "A warning that Intel Macs are no longer supported.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Ollama (Local)" : {
|
"Ollama (Local)" : {
|
||||||
"comment" : "A label displayed above the credits information for the local Ollie.",
|
"comment" : "A label displayed above the credits information for the local Ollie.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@@ -3574,6 +3657,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"OpenRouter Balance" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"OpenRouter Credits" : {
|
"OpenRouter Credits" : {
|
||||||
"comment" : "A heading for the user's OpenRouter credits.",
|
"comment" : "A heading for the user's OpenRouter credits.",
|
||||||
@@ -3604,6 +3690,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Output" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Prompt" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Read access (always enabled)" : {
|
"Read access (always enabled)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3632,6 +3724,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Reasoning" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Remote: %@" : {
|
"Remote: %@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3690,6 +3785,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Run History" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Run some agents to see usage statistics" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Running locally — no credits needed!" : {
|
"Running locally — no credits needed!" : {
|
||||||
"comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.",
|
"comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.",
|
||||||
@@ -3721,6 +3822,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Runs" : {
|
||||||
|
"comment" : "A column header for the number of runs.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Security Recommendation" : {
|
"Security Recommendation" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -3866,6 +3971,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Sort" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"SSH Key" : {
|
"SSH Key" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4180,6 +4288,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Thinking…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"This default prompt is always included to ensure accurate, helpful responses." : {
|
"This default prompt is always included to ensure accurate, helpful responses." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4296,6 +4407,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Total" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Total Credits" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Total Used" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Try adjusting your search or filters" : {
|
"Try adjusting your search or filters" : {
|
||||||
"comment" : "A description of the error that occurs when no models match the user's search.",
|
"comment" : "A description of the error that occurs when no models match the user's search.",
|
||||||
@@ -4444,6 +4564,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Usage" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Use @filename to attach files to your message" : {
|
"Use @filename to attach files to your message" : {
|
||||||
"comment" : "A description of how to attach files to a message.",
|
"comment" : "A description of how to attach files to a message.",
|
||||||
@@ -4565,6 +4688,7 @@
|
|||||||
},
|
},
|
||||||
"v%@" : {
|
"v%@" : {
|
||||||
"comment" : "A label showing the current version of oAI.",
|
"comment" : "A label showing the current version of oAI.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -4744,6 +4868,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"β" : {
|
||||||
|
"comment" : "A beta badge.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
//
|
||||||
|
// JarvisModels.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Data models for the Jarvis (oAI-Web) API integration.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Agent
|
||||||
|
|
||||||
|
struct JarvisAgent: Identifiable, Codable, Hashable, Sendable {
|
||||||
|
let id: String
|
||||||
|
var name: String
|
||||||
|
var description: String
|
||||||
|
var prompt: String
|
||||||
|
var model: String
|
||||||
|
var enabled: Bool
|
||||||
|
var schedule: String?
|
||||||
|
var canCreateSubagents: Bool
|
||||||
|
var allowedTools: [String]
|
||||||
|
var maxToolCalls: Int?
|
||||||
|
var promptMode: String
|
||||||
|
let createdAt: String?
|
||||||
|
var isRunning: Bool?
|
||||||
|
var lastRunAt: String?
|
||||||
|
var lastRunStatus: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, description, prompt, model, enabled, schedule
|
||||||
|
case canCreateSubagents = "can_create_subagents"
|
||||||
|
case allowedTools = "allowed_tools"
|
||||||
|
case maxToolCalls = "max_tool_calls"
|
||||||
|
case promptMode = "prompt_mode"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case isRunning = "is_running"
|
||||||
|
case lastRunAt = "last_run_at"
|
||||||
|
case lastRunStatus = "last_run_status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Agent Input (create / update)
|
||||||
|
|
||||||
|
struct JarvisAgentInput: Codable, Sendable {
|
||||||
|
var name: String
|
||||||
|
var prompt: String
|
||||||
|
var model: String
|
||||||
|
var description: String = ""
|
||||||
|
var enabled: Bool = true
|
||||||
|
var schedule: String? = nil
|
||||||
|
var canCreateSubagents: Bool = false
|
||||||
|
var allowedTools: [String] = []
|
||||||
|
var maxToolCalls: Int? = nil
|
||||||
|
var promptMode: String = "combined"
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, prompt, model, description, enabled, schedule
|
||||||
|
case canCreateSubagents = "can_create_subagents"
|
||||||
|
case allowedTools = "allowed_tools"
|
||||||
|
case maxToolCalls = "max_tool_calls"
|
||||||
|
case promptMode = "prompt_mode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Agent Run
|
||||||
|
|
||||||
|
struct JarvisAgentRun: Identifiable, Codable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let agentId: String?
|
||||||
|
let status: String // "running" | "completed" | "failed" | "stopped"
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
let output: String?
|
||||||
|
let error: String?
|
||||||
|
let costUsd: Double?
|
||||||
|
let inputTokens: Int?
|
||||||
|
let outputTokens: Int?
|
||||||
|
let triggerType: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, status, output, error
|
||||||
|
case agentId = "agent_id"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
case costUsd = "cost_usd"
|
||||||
|
case inputTokens = "input_tokens"
|
||||||
|
case outputTokens = "output_tokens"
|
||||||
|
case triggerType = "trigger_type"
|
||||||
|
}
|
||||||
|
|
||||||
|
var isActive: Bool { status == "running" }
|
||||||
|
|
||||||
|
var totalTokens: Int { (inputTokens ?? 0) + (outputTokens ?? 0) }
|
||||||
|
|
||||||
|
var formattedStarted: String {
|
||||||
|
guard let s = startedAt else { return "—" }
|
||||||
|
return isoFormatter.string(from: isoParser.date(from: s) ?? Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDuration: String? {
|
||||||
|
guard let s = startedAt, let f = finishedAt,
|
||||||
|
let sd = isoParser.date(from: s), let fd = isoParser.date(from: f) else { return nil }
|
||||||
|
let secs = Int(fd.timeIntervalSince(sd))
|
||||||
|
if secs < 60 { return "\(secs)s" }
|
||||||
|
return "\(secs / 60)m \(secs % 60)s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let isoParser: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let isoFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateStyle = .short
|
||||||
|
f.timeStyle = .short
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - Usage
|
||||||
|
|
||||||
|
struct JarvisUsageStat: Identifiable, Codable, Sendable {
|
||||||
|
let agentId: String?
|
||||||
|
let agentName: String?
|
||||||
|
let model: String?
|
||||||
|
let runCount: Int?
|
||||||
|
let totalInputTokens: Int?
|
||||||
|
let totalOutputTokens: Int?
|
||||||
|
let totalCostUsd: Double?
|
||||||
|
|
||||||
|
var id: String { agentId ?? agentName ?? "unknown" }
|
||||||
|
var totalTokens: Int { (totalInputTokens ?? 0) + (totalOutputTokens ?? 0) }
|
||||||
|
var displayName: String { agentName ?? agentId ?? "Unknown" }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case agentId = "agent_id"
|
||||||
|
case agentName = "agent_name"
|
||||||
|
case model
|
||||||
|
case runCount = "runs"
|
||||||
|
case totalInputTokens = "input_tokens"
|
||||||
|
case totalOutputTokens = "output_tokens"
|
||||||
|
case totalCostUsd = "cost_usd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Usage Response (top-level wrapper)
|
||||||
|
|
||||||
|
struct JarvisUsageResponse: Decodable, Sendable {
|
||||||
|
let summary: JarvisUsageSummary?
|
||||||
|
let byAgent: [JarvisUsageStat]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case summary
|
||||||
|
case byAgent = "by_agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JarvisUsageSummary: Codable, Sendable {
|
||||||
|
let runs: Int?
|
||||||
|
let inputTokens: Int?
|
||||||
|
let outputTokens: Int?
|
||||||
|
let costUsd: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case runs
|
||||||
|
case inputTokens = "input_tokens"
|
||||||
|
case outputTokens = "output_tokens"
|
||||||
|
case costUsd = "cost_usd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Credits
|
||||||
|
|
||||||
|
struct JarvisCreditsResponse: Codable, Sendable {
|
||||||
|
let totalCredits: Double?
|
||||||
|
let totalUsage: Double?
|
||||||
|
let balance: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case totalCredits = "total_credits"
|
||||||
|
case totalUsage = "total_usage"
|
||||||
|
case balance
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingBalance: Double? {
|
||||||
|
if let b = balance { return b }
|
||||||
|
if let c = totalCredits, let u = totalUsage { return c - u }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Queue / System status
|
||||||
|
|
||||||
|
struct JarvisQueueStatus: Codable, Sendable {
|
||||||
|
let paused: Bool?
|
||||||
|
let queueLength: Int?
|
||||||
|
let runningCount: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case paused
|
||||||
|
case queueLength = "queue_length"
|
||||||
|
case runningCount = "running_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum JarvisError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case noAPIKey
|
||||||
|
case invalidResponse
|
||||||
|
case serverError(Int, String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL: return "Invalid Jarvis URL"
|
||||||
|
case .noAPIKey: return "No API key configured — add one in Settings → Jarvis"
|
||||||
|
case .invalidResponse: return "Invalid server response"
|
||||||
|
case .serverError(let c, let m): return "Server error \(c): \(m)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//
|
||||||
|
// ModelCategory.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Category tags for AI models, inferred from model name/id/description.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
enum ModelCategory: String, CaseIterable, Codable, Sendable {
|
||||||
|
case programming = "Programming"
|
||||||
|
case math = "Math"
|
||||||
|
case medical = "Medical"
|
||||||
|
case translation = "Translation"
|
||||||
|
case roleplay = "Roleplay"
|
||||||
|
case creative = "Creative"
|
||||||
|
case science = "Science"
|
||||||
|
case finance = "Finance"
|
||||||
|
case legal = "Legal"
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .programming: return .blue
|
||||||
|
case .math: return .orange
|
||||||
|
case .medical: return .red
|
||||||
|
case .translation: return .teal
|
||||||
|
case .roleplay: return .pink
|
||||||
|
case .creative: return .purple
|
||||||
|
case .science: return .green
|
||||||
|
case .finance: return Color(red: 0.75, green: 0.60, blue: 0.0)
|
||||||
|
case .legal: return Color(red: 0.55, green: 0.40, blue: 0.20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .programming: return "chevron.left.forwardslash.chevron.right"
|
||||||
|
case .math: return "function"
|
||||||
|
case .medical: return "cross.fill"
|
||||||
|
case .translation: return "globe"
|
||||||
|
case .roleplay: return "theatermasks.fill"
|
||||||
|
case .creative: return "pencil.and.outline"
|
||||||
|
case .science: return "atom"
|
||||||
|
case .finance: return "chart.line.uptrend.xyaxis"
|
||||||
|
case .legal: return "building.columns.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Inference
|
||||||
|
|
||||||
|
/// Infer categories from a model's name, id, and description.
|
||||||
|
static func infer(name: String, id: String, description: String?) -> [ModelCategory] {
|
||||||
|
let nameId = (name + " " + id).lowercased()
|
||||||
|
let desc = description?.lowercased() ?? ""
|
||||||
|
return allCases.filter { $0.matches(nameId: nameId, desc: desc) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matches(nameId: String, desc: String) -> Bool {
|
||||||
|
if nameKeywords.contains(where: { nameId.contains($0) }) { return true }
|
||||||
|
if desc.count > 40 && descKeywords.contains(where: { desc.contains($0) }) { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patterns matched against lowercased "name + id" string
|
||||||
|
private var nameKeywords: [String] {
|
||||||
|
switch self {
|
||||||
|
case .programming:
|
||||||
|
return ["code", "coder", "codex", "codellama", "starcoder", "phind",
|
||||||
|
"codestral", "opencoder", "swe-", "devin-", "wizard-code",
|
||||||
|
"replit-code", "qwen-coder", "deepseek-coder", "devstral",
|
||||||
|
"granite-code", "yi-coder", "artigenz", "wavecoder",
|
||||||
|
"programming", "software-", "cursor-"]
|
||||||
|
case .math:
|
||||||
|
return ["math", "mathem", "numina", "minerva-math", "wizard-math",
|
||||||
|
"deepseek-math", "qwen-math", "numinamath", "mathstral",
|
||||||
|
"qwq", "internlm-math", "mammoth", "mathcoder", "orion-math",
|
||||||
|
"abel-", "metamath"]
|
||||||
|
case .medical:
|
||||||
|
return ["medical", "meditron", "med42", "medllama", "biomed",
|
||||||
|
"health-llm", "biosage", "clinicalbert", "pubmedbert",
|
||||||
|
"clinical", "llama-med", "openbiomed", "pmc-llama",
|
||||||
|
"doctorglm", "biolm", "biomistral", "medalpaca",
|
||||||
|
"medpalm", "pharmallm", "mimic"]
|
||||||
|
case .translation:
|
||||||
|
return ["nllb", "madlad", "-aya-", "seamless", "tower-instruct",
|
||||||
|
"alma-", "bayling", "opus-mt", "m2m-100", "mbart",
|
||||||
|
"translate", "multilingual-", "xglm", "madlad-400"]
|
||||||
|
case .roleplay:
|
||||||
|
return ["roleplay", "role-play", "mytho", "capybara", "cinematika",
|
||||||
|
"manticore", "weaver-", "noromaid", "airoboros", "toppy",
|
||||||
|
"dolphin", "hermes", "openhermes", "psyfighter",
|
||||||
|
"bluemoon", "midnight", "remm", "rose-20b"]
|
||||||
|
case .creative:
|
||||||
|
return ["creative-writing", "story-writer", "storyllm", "fimbulvetr",
|
||||||
|
"rp-", "goliath", "lzlv", "mlewd"]
|
||||||
|
case .science:
|
||||||
|
return ["scibert", "biogpt", "galactica", "science-llm", "scillm",
|
||||||
|
"darwin-", "newton-", "eureka-", "sci-"]
|
||||||
|
case .finance:
|
||||||
|
return ["fingpt", "finma", "finance-llm", "financellm", "pixiu",
|
||||||
|
"flang-", "alphafin", "bloom-finance", "finbert",
|
||||||
|
"stockgpt", "traderllm"]
|
||||||
|
case .legal:
|
||||||
|
return ["lawbench", "legalbench", "legalbert", "lawgpt", "legal-llm",
|
||||||
|
"lawyerllm", "legalai", "chatlaw", "jurisllm"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phrases matched against lowercased description (only when description > 40 chars)
|
||||||
|
private var descKeywords: [String] {
|
||||||
|
switch self {
|
||||||
|
case .programming:
|
||||||
|
return ["code generation", "designed for coding", "built for code",
|
||||||
|
"coding-focused", "programming assistant", "specialized for code",
|
||||||
|
"optimized for coding", "coding tasks", "software engineering",
|
||||||
|
"for developers", "code completion", "software development",
|
||||||
|
"coding and", "and coding", "writing code"]
|
||||||
|
case .math:
|
||||||
|
return ["mathematical reasoning", "math competition", "math olympiad",
|
||||||
|
"designed for math", "theorem proving", "quantitative reasoning",
|
||||||
|
"numerical problem", "math, code", "math and code",
|
||||||
|
"mathematics and", "advanced math", "math tasks",
|
||||||
|
"solving math", "competition math", "math problems"]
|
||||||
|
case .medical:
|
||||||
|
return ["medical knowledge", "clinical reasoning", "healthcare",
|
||||||
|
"biomedical research", "medical question", "trained on medical",
|
||||||
|
"medical domain", "medical literature", "clinical decision",
|
||||||
|
"health information", "medical text", "medical imaging"]
|
||||||
|
case .translation:
|
||||||
|
return ["machine translation", "language translation",
|
||||||
|
"multilingual translation", "cross-lingual",
|
||||||
|
"translation tasks", "translation between",
|
||||||
|
"natural language translation"]
|
||||||
|
case .roleplay:
|
||||||
|
return ["designed for roleplay", "roleplay scenarios",
|
||||||
|
"creative roleplay", "interactive roleplay", "character roleplay",
|
||||||
|
"role-playing", "roleplaying", "uncensored", "nsfw",
|
||||||
|
"adult content", "creative fiction", "interactive story"]
|
||||||
|
case .creative:
|
||||||
|
return ["creative writing", "storytelling", "narrative generation",
|
||||||
|
"fiction writing", "prose generation", "story writing",
|
||||||
|
"creative text", "story generation", "write stories"]
|
||||||
|
case .science:
|
||||||
|
return ["scientific literature", "scientific research",
|
||||||
|
"chemistry tasks", "biology research", "physics",
|
||||||
|
"scientific reasoning", "science tasks", "stem tasks",
|
||||||
|
"scientific knowledge"]
|
||||||
|
case .finance:
|
||||||
|
return ["financial analysis", "quantitative finance",
|
||||||
|
"financial modeling", "market analysis", "financial reasoning",
|
||||||
|
"investment analysis", "economic analysis", "trading"]
|
||||||
|
case .legal:
|
||||||
|
return ["legal document", "case law", "legal reasoning",
|
||||||
|
"legal text", "law and legal", "legal questions",
|
||||||
|
"legal analysis", "legal research", "contract analysis"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
|
|||||||
let capabilities: ModelCapabilities
|
let capabilities: ModelCapabilities
|
||||||
var architecture: Architecture? = nil
|
var architecture: Architecture? = nil
|
||||||
var topProvider: String? = nil
|
var topProvider: String? = nil
|
||||||
|
var categories: [ModelCategory] = []
|
||||||
|
|
||||||
struct Pricing: Codable, Hashable {
|
struct Pricing: Codable, Hashable {
|
||||||
let prompt: Double // per 1M tokens
|
let prompt: Double // per 1M tokens
|
||||||
|
|||||||
@@ -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,6 +48,11 @@ struct OpenRouterChatRequest: Codable {
|
|||||||
let toolChoice: String?
|
let toolChoice: String?
|
||||||
let modalities: [String]?
|
let modalities: [String]?
|
||||||
let reasoning: ReasoningAPIConfig?
|
let reasoning: ReasoningAPIConfig?
|
||||||
|
let cacheControl: CacheControl?
|
||||||
|
|
||||||
|
struct CacheControl: Codable {
|
||||||
|
let type: String
|
||||||
|
}
|
||||||
|
|
||||||
struct APIMessage: Codable {
|
struct APIMessage: Codable {
|
||||||
let role: String
|
let role: String
|
||||||
@@ -138,6 +143,7 @@ struct OpenRouterChatRequest: Codable {
|
|||||||
case toolChoice = "tool_choice"
|
case toolChoice = "tool_choice"
|
||||||
case modalities
|
case modalities
|
||||||
case reasoning
|
case reasoning
|
||||||
|
case cacheControl = "cache_control"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,11 +231,23 @@ struct OpenRouterChatResponse: Codable {
|
|||||||
let promptTokens: Int
|
let promptTokens: Int
|
||||||
let completionTokens: Int
|
let completionTokens: Int
|
||||||
let totalTokens: Int
|
let totalTokens: Int
|
||||||
|
let promptTokensDetails: PromptTokensDetails?
|
||||||
|
|
||||||
|
struct PromptTokensDetails: Codable {
|
||||||
|
let cachedTokens: Int?
|
||||||
|
let cacheWriteTokens: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case cachedTokens = "cached_tokens"
|
||||||
|
case cacheWriteTokens = "cache_write_tokens"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case promptTokens = "prompt_tokens"
|
case promptTokens = "prompt_tokens"
|
||||||
case completionTokens = "completion_tokens"
|
case completionTokens = "completion_tokens"
|
||||||
case totalTokens = "total_tokens"
|
case totalTokens = "total_tokens"
|
||||||
|
case promptTokensDetails = "prompt_tokens_details"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class OpenRouterProvider: AIProvider {
|
|||||||
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
|
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
|
||||||
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
|
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
|
||||||
|
|
||||||
return ModelInfo(
|
var info = ModelInfo(
|
||||||
id: modelData.id,
|
id: modelData.id,
|
||||||
name: modelData.name,
|
name: modelData.name,
|
||||||
description: modelData.description,
|
description: modelData.description,
|
||||||
@@ -122,6 +122,12 @@ class OpenRouterProvider: AIProvider {
|
|||||||
},
|
},
|
||||||
topProvider: modelData.id.components(separatedBy: "/").first
|
topProvider: modelData.id.components(separatedBy: "/").first
|
||||||
)
|
)
|
||||||
|
info.categories = ModelCategory.infer(
|
||||||
|
name: modelData.name,
|
||||||
|
id: modelData.id,
|
||||||
|
description: modelData.description
|
||||||
|
)
|
||||||
|
return info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,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"
|
||||||
@@ -382,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,
|
||||||
@@ -392,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,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,
|
||||||
@@ -420,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)),
|
||||||
@@ -440,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,
|
||||||
@@ -454,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<li><a href="#email-handler">Email Handler (AI Assistant)</a></li>
|
<li><a href="#email-handler">Email Handler (AI Assistant)</a></li>
|
||||||
<li><a href="#shortcuts">Shortcuts (Prompt Templates)</a></li>
|
<li><a href="#shortcuts">Shortcuts (Prompt Templates)</a></li>
|
||||||
<li><a href="#agent-skills">Agent Skills (SKILL.md)</a></li>
|
<li><a href="#agent-skills">Agent Skills (SKILL.md)</a></li>
|
||||||
|
<li><a href="#anytype">Anytype Integration</a></li>
|
||||||
<li><a href="#bash-execution">Bash Execution</a></li>
|
<li><a href="#bash-execution">Bash Execution</a></li>
|
||||||
<li><a href="#icloud-backup">iCloud Backup</a></li>
|
<li><a href="#icloud-backup">iCloud Backup</a></li>
|
||||||
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
|
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
|
||||||
@@ -117,14 +118,23 @@
|
|||||||
<h3>Sorting</h3>
|
<h3>Sorting</h3>
|
||||||
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
|
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Default</strong> — provider order</li>
|
<li><strong>Default</strong> — provider order, with favourites floated to the top</li>
|
||||||
<li><strong>Price: Low to High</strong> — cheapest per million tokens first</li>
|
<li><strong>Price: Low to High</strong> — cheapest per million tokens first</li>
|
||||||
<li><strong>Price: High to Low</strong> — most capable/expensive first</li>
|
<li><strong>Price: High to Low</strong> — most capable/expensive first</li>
|
||||||
<li><strong>Context: High to Low</strong> — largest context window first</li>
|
<li><strong>Context: High to Low</strong> — largest context window first</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>Favourite Models</h3>
|
||||||
|
<p>Click the <strong>★</strong> star next to any model name to mark it as a favourite. Favourites:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Float to the top of the Default sort order</li>
|
||||||
|
<li>Can be filtered to show only with the <strong>☆</strong> star button in the toolbar</li>
|
||||||
|
<li>Are shown as a filled yellow star ★ in the model row, the Model Info sheet, and the header bar</li>
|
||||||
|
<li>Are shared across all three places — toggling in any one updates all</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3>Model Information</h3>
|
<h3>Model Information</h3>
|
||||||
<p>Click the <strong>ⓘ</strong> icon on any model row to open a full details sheet — context length, pricing, capabilities, and description — without selecting that model. You can also type:</p>
|
<p>Click the <strong>ⓘ</strong> icon on any model row to open a full details sheet — context length, pricing, capabilities, and description — without selecting that model. The sheet also has a ★ star button to toggle favourites. You can also type:</p>
|
||||||
<code class="command">/info</code>
|
<code class="command">/info</code>
|
||||||
<p class="note">Shows information about the currently selected model.</p>
|
<p class="note">Shows information about the currently selected model.</p>
|
||||||
|
|
||||||
@@ -132,7 +142,7 @@
|
|||||||
<p>Use <kbd>↑</kbd> / <kbd>↓</kbd> to move through the list, <kbd>Return</kbd> to select the highlighted model.</p>
|
<p>Use <kbd>↑</kbd> / <kbd>↓</kbd> to move through the list, <kbd>Return</kbd> to select the highlighted model.</p>
|
||||||
|
|
||||||
<h3>Default Model</h3>
|
<h3>Default Model</h3>
|
||||||
<p>Your selected model is automatically saved and will be restored when you restart the app.</p>
|
<p>Set a model that oAI always opens with in <strong>Settings → General → Model Settings → Default Model</strong>. Click <strong>Choose…</strong> to pick from the full model list, or <strong>Clear</strong> to remove the default. Switching models during a chat session does <em>not</em> change your saved default — it only changes the current session.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Sending Messages -->
|
<!-- Sending Messages -->
|
||||||
@@ -1361,6 +1371,48 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Keyboard Shortcuts -->
|
<!-- Keyboard Shortcuts -->
|
||||||
|
<!-- Anytype Integration -->
|
||||||
|
<section id="anytype">
|
||||||
|
<h2>Anytype Integration</h2>
|
||||||
|
<p>oAI can connect to your local <a href="https://anytype.io" target="_blank">Anytype</a> desktop app, giving the AI read and write access to your personal knowledge base. All data stays on your machine — the API is local-only.</p>
|
||||||
|
|
||||||
|
<h3>Requirements</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Anytype desktop app installed and running</li>
|
||||||
|
<li>An API key generated inside Anytype (Settings → Integrations)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Setup</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open oAI Settings → Anytype tab</li>
|
||||||
|
<li>Enable the toggle</li>
|
||||||
|
<li>Enter your API key (leave the URL as the default unless your setup is unusual)</li>
|
||||||
|
<li>Click <strong>Test Connection</strong> — a success message will show how many spaces were found</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>What the AI Can Do</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Search</strong> — find objects by keyword across all spaces or within a specific one</li>
|
||||||
|
<li><strong>Read</strong> — open any object and read its full markdown content</li>
|
||||||
|
<li><strong>Create</strong> — make new notes, tasks, or pages</li>
|
||||||
|
<li><strong>Append</strong> — add content to the end of an existing object without touching the rest (recommended for edits)</li>
|
||||||
|
<li><strong>Update</strong> — rewrite the full body of an object (use only when truly restructuring content)</li>
|
||||||
|
<li><strong>Checkboxes</strong> — toggle individual to-do checkboxes by text match, or mark tasks done via their native relation</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tip">
|
||||||
|
<strong>💡 Tip — Append vs Update:</strong> Use <em>append</em> whenever you want to add content to an existing note. It fetches the current body, adds your new content at the end, and saves — leaving all existing text, links, and internal Anytype references intact. <em>Update</em> replaces the entire body and can degrade rich Anytype internal links (anytype://...) to plain text.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Example Prompts</h3>
|
||||||
|
<ul>
|
||||||
|
<li>"Search my Anytype for notes about Swift concurrency"</li>
|
||||||
|
<li>"Create a new task called 'Review PR #42' in my Work space"</li>
|
||||||
|
<li>"Add today's meeting summary to my Weekly Notes object"</li>
|
||||||
|
<li>"Mark the 'Buy groceries' checkbox as done in my Shopping List"</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="keyboard-shortcuts">
|
<section id="keyboard-shortcuts">
|
||||||
<h2>Keyboard Shortcuts</h2>
|
<h2>Keyboard Shortcuts</h2>
|
||||||
<p>Work faster with these keyboard shortcuts.</p>
|
<p>Work faster with these keyboard shortcuts.</p>
|
||||||
@@ -1549,6 +1601,27 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
|||||||
<li><strong>Restore from File…</strong> — imports settings from a backup file</li>
|
<li><strong>Restore from File…</strong> — imports settings from a backup file</li>
|
||||||
<li>API keys and credentials are excluded from backups and must be re-entered after restore</li>
|
<li>API keys and credentials are excluded from backups and must be re-entered after restore</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>Anytype Tab</h3>
|
||||||
|
<p>Connect oAI to your local <a href="https://anytype.io" target="_blank">Anytype</a> desktop app so the AI can search, read, and add content to your knowledge base.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Enable Anytype</strong> — toggle to activate the integration</li>
|
||||||
|
<li><strong>API URL</strong> — local Anytype API endpoint (default: <code>http://127.0.0.1:31009</code>)</li>
|
||||||
|
<li><strong>API Key</strong> — generated in Anytype → Settings → Integrations</li>
|
||||||
|
<li><strong>Test Connection</strong> — verify connectivity and list available spaces</li>
|
||||||
|
</ul>
|
||||||
|
<p>When enabled, the AI has access to these tools:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>anytype_search_global</code> / <code>anytype_search_space</code> — search across all or a specific space</li>
|
||||||
|
<li><code>anytype_list_spaces</code> / <code>anytype_get_space_objects</code> — explore your spaces</li>
|
||||||
|
<li><code>anytype_get_object</code> — read the full markdown body of any object</li>
|
||||||
|
<li><code>anytype_create_object</code> — create a new note, task, or page</li>
|
||||||
|
<li><code>anytype_append_to_object</code> — <strong>add content to an existing object without rewriting it</strong> (preferred for edits — preserves Anytype internal links)</li>
|
||||||
|
<li><code>anytype_update_object</code> — replace the full body (use sparingly; prefer append)</li>
|
||||||
|
<li><code>anytype_toggle_checkbox</code> — surgically check/uncheck a to-do item by text match</li>
|
||||||
|
<li><code>anytype_set_done</code> — mark a task done/undone via its native relation</li>
|
||||||
|
</ul>
|
||||||
|
<p class="note"><strong>Note:</strong> The Anytype desktop app must be running for the integration to work. The API is local-only — no data leaves your machine.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- System Prompts -->
|
<!-- System Prompts -->
|
||||||
|
|||||||
@@ -105,13 +105,28 @@ class AnytypeMCPService {
|
|||||||
],
|
],
|
||||||
required: ["space_id", "name"]
|
required: ["space_id", "name"]
|
||||||
),
|
),
|
||||||
|
makeTool(
|
||||||
|
name: "anytype_append_to_object",
|
||||||
|
description: """
|
||||||
|
Append new markdown content to the end of an existing Anytype object without touching the existing body. \
|
||||||
|
This is the PREFERRED way to add content to existing notes, pages, or tasks — \
|
||||||
|
it preserves all Anytype internal links (anytype://...) and mention blocks exactly. \
|
||||||
|
Use this instead of anytype_update_object whenever you are adding information rather than rewriting.
|
||||||
|
""",
|
||||||
|
properties: [
|
||||||
|
"space_id": prop("string", "The ID of the space containing the object"),
|
||||||
|
"object_id": prop("string", "The ID of the object to append to"),
|
||||||
|
"content": prop("string", "Markdown content to append at the end of the object body")
|
||||||
|
],
|
||||||
|
required: ["space_id", "object_id", "content"]
|
||||||
|
),
|
||||||
makeTool(
|
makeTool(
|
||||||
name: "anytype_update_object",
|
name: "anytype_update_object",
|
||||||
description: """
|
description: """
|
||||||
Replace the full markdown body or rename an Anytype object. \
|
Replace the full markdown body or rename an Anytype object. \
|
||||||
IMPORTANT: For toggling a checkbox (to-do item), use anytype_toggle_checkbox instead — \
|
WARNING: This replaces the ENTIRE body — prefer anytype_append_to_object for adding content \
|
||||||
it is safer and does not risk modifying other content. \
|
to existing objects, as full replacement degrades rich Anytype internal links (anytype://...) to plain text. \
|
||||||
Use anytype_update_object ONLY for large content changes (adding paragraphs, rewriting sections, etc.). \
|
Use this ONLY when you truly need to rewrite or restructure existing content. \
|
||||||
CRITICAL RULES when using this tool: \
|
CRITICAL RULES when using this tool: \
|
||||||
1) Always call anytype_get_object first to get the current EXACT markdown. \
|
1) Always call anytype_get_object first to get the current EXACT markdown. \
|
||||||
2) Make ONLY the minimal requested change — nothing else. \
|
2) Make ONLY the minimal requested change — nothing else. \
|
||||||
@@ -214,6 +229,14 @@ class AnytypeMCPService {
|
|||||||
let type_ = args["type"] as? String ?? "note"
|
let type_ = args["type"] as? String ?? "note"
|
||||||
return try await createObject(spaceId: spaceId, name: name, body: body, type: type_)
|
return try await createObject(spaceId: spaceId, name: name, body: body, type: type_)
|
||||||
|
|
||||||
|
case "anytype_append_to_object":
|
||||||
|
guard let spaceId = args["space_id"] as? String,
|
||||||
|
let objectId = args["object_id"] as? String,
|
||||||
|
let content = args["content"] as? String else {
|
||||||
|
return ["error": "Missing required parameters: space_id, object_id, content"]
|
||||||
|
}
|
||||||
|
return try await appendToObject(spaceId: spaceId, objectId: objectId, content: content)
|
||||||
|
|
||||||
case "anytype_update_object":
|
case "anytype_update_object":
|
||||||
guard let spaceId = args["space_id"] as? String,
|
guard let spaceId = args["space_id"] as? String,
|
||||||
let objectId = args["object_id"] as? String else {
|
let objectId = args["object_id"] as? String else {
|
||||||
@@ -351,6 +374,29 @@ class AnytypeMCPService {
|
|||||||
return ["success": true, "message": "Object created"]
|
return ["success": true, "message": "Object created"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func appendToObject(spaceId: String, objectId: String, content: String) async throws -> [String: Any] {
|
||||||
|
// Fetch current body
|
||||||
|
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil)
|
||||||
|
guard let object = result["object"] as? [String: Any] else {
|
||||||
|
return ["error": "Object not found"]
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: String
|
||||||
|
if let md = object["markdown"] as? String { existing = md }
|
||||||
|
else if let body = object["body"] as? String { existing = body }
|
||||||
|
else { existing = "" }
|
||||||
|
|
||||||
|
let separator = existing.isEmpty ? "" : "\n\n"
|
||||||
|
let newMarkdown = existing + separator + content
|
||||||
|
|
||||||
|
_ = try await request(
|
||||||
|
endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)",
|
||||||
|
method: "PATCH",
|
||||||
|
body: ["markdown": newMarkdown]
|
||||||
|
)
|
||||||
|
return ["success": true, "message": "Content appended successfully"]
|
||||||
|
}
|
||||||
|
|
||||||
private func updateObject(spaceId: String, objectId: String, name: String?, body: String?) async throws -> [String: Any] {
|
private func updateObject(spaceId: String, objectId: String, name: String?, body: String?) async throws -> [String: Any] {
|
||||||
var requestBody: [String: Any] = [:]
|
var requestBody: [String: Any] = [:]
|
||||||
if let name = name { requestBody["name"] = name }
|
if let name = name { requestBody["name"] = name }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//
|
||||||
|
// JarvisService.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// HTTP client for the Jarvis (oAI-Web) REST API.
|
||||||
|
// Auth: Authorization: Bearer <api-key>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
final class JarvisService: Sendable {
|
||||||
|
static let shared = JarvisService()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.oai.oAI", category: "jarvis")
|
||||||
|
|
||||||
|
private var baseURL: String { SettingsService.shared.jarvisURL }
|
||||||
|
private var apiKey: String? { SettingsService.shared.jarvisAPIKey }
|
||||||
|
|
||||||
|
// MARK: - Agents
|
||||||
|
|
||||||
|
func listAgents() async throws -> [JarvisAgent] {
|
||||||
|
try await get("/api/agents")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAgent(_ input: JarvisAgentInput) async throws -> JarvisAgent {
|
||||||
|
try await post("/api/agents", body: input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAgent(id: String, _ input: JarvisAgentInput) async throws -> JarvisAgent {
|
||||||
|
try await put("/api/agents/\(id)", body: input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAgent(id: String) async throws {
|
||||||
|
try await voidRequest("DELETE", path: "/api/agents/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleAgent(id: String) async throws -> JarvisAgent {
|
||||||
|
try await post("/api/agents/\(id)/toggle", body: Empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAgent(id: String) async throws {
|
||||||
|
try await voidRequest("POST", path: "/api/agents/\(id)/run")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAgent(id: String) async throws {
|
||||||
|
try await voidRequest("POST", path: "/api/agents/\(id)/stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentRuns(id: String) async throws -> [JarvisAgentRun] {
|
||||||
|
try await get("/api/agents/\(id)/runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Usage
|
||||||
|
|
||||||
|
func usage() async throws -> [JarvisUsageStat] {
|
||||||
|
let data = try await rawData("GET", path: "/api/usage")
|
||||||
|
// API returns {summary:{...}, by_agent:[...], chat:{...}}
|
||||||
|
if let w = try? JSONDecoder().decode(JarvisUsageResponse.self, from: data) {
|
||||||
|
return w.byAgent ?? []
|
||||||
|
}
|
||||||
|
// Fallback: plain array
|
||||||
|
if let arr = try? JSONDecoder().decode([JarvisUsageStat].self, from: data) { return arr }
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
func credits() async throws -> JarvisCreditsResponse {
|
||||||
|
try await get("/api/usage/openrouter-credits")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Control
|
||||||
|
|
||||||
|
func queueStatus() async throws -> JarvisQueueStatus {
|
||||||
|
try await get("/api/status")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseAll() async throws {
|
||||||
|
try await voidRequest("POST", path: "/api/pause")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeAll() async throws {
|
||||||
|
try await voidRequest("POST", path: "/api/resume")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection test
|
||||||
|
|
||||||
|
func testConnection() async -> Bool {
|
||||||
|
guard !baseURL.isEmpty, let key = apiKey, !key.isEmpty else { return false }
|
||||||
|
do {
|
||||||
|
let _: [JarvisAgent] = try await get("/api/agents")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
log.warning("Jarvis connection test failed: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HTTP core
|
||||||
|
|
||||||
|
private func get<T: Decodable>(_ path: String) async throws -> T {
|
||||||
|
let data = try await rawData("GET", path: path)
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||||
|
let data = try await rawData("POST", path: path, body: body)
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||||
|
let data = try await rawData("PUT", path: path, body: body)
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func voidRequest(_ method: String, path: String) async throws {
|
||||||
|
_ = try await rawData(method, path: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rawData<B: Encodable>(_ method: String, path: String, body: B? = nil as Empty?) async throws -> Data {
|
||||||
|
guard let url = URL(string: baseURL.trimmingCharacters(in: .whitespaces) + path) else {
|
||||||
|
throw JarvisError.invalidURL
|
||||||
|
}
|
||||||
|
guard let key = apiKey, !key.isEmpty else {
|
||||||
|
throw JarvisError.noAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = method
|
||||||
|
req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
req.timeoutInterval = 30
|
||||||
|
|
||||||
|
if let body {
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = try JSONEncoder().encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw JarvisError.invalidResponse
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let msg = (try? JSONDecoder().decode(JarvisErrBody.self, from: data))?.detail ?? "HTTP \(http.statusCode)"
|
||||||
|
log.error("Jarvis \(method) \(path) → \(http.statusCode): \(msg)")
|
||||||
|
throw JarvisError.serverError(http.statusCode, msg)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Empty: Codable {}
|
||||||
|
private struct JarvisErrBody: Decodable { let detail: String? }
|
||||||
@@ -43,6 +43,7 @@ class SettingsService {
|
|||||||
static let googleSearchEngineID = "googleSearchEngineID"
|
static let googleSearchEngineID = "googleSearchEngineID"
|
||||||
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
|
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
|
||||||
static let paperlessAPIToken = "paperlessAPIToken"
|
static let paperlessAPIToken = "paperlessAPIToken"
|
||||||
|
static let jarvisAPIKey = "jarvisAPIKey"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Old keychain keys (for migration only)
|
// Old keychain keys (for migration only)
|
||||||
@@ -299,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 {
|
||||||
@@ -430,6 +449,31 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Favorite Models
|
||||||
|
|
||||||
|
var favoriteModelIds: Set<String> {
|
||||||
|
get {
|
||||||
|
guard let json = cache["favoriteModelIds"],
|
||||||
|
let data = json.data(using: .utf8),
|
||||||
|
let ids = try? JSONDecoder().decode([String].self, from: data) else { return [] }
|
||||||
|
return Set(ids)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let sorted = newValue.sorted()
|
||||||
|
if let data = try? JSONEncoder().encode(sorted),
|
||||||
|
let json = String(data: data, encoding: .utf8) {
|
||||||
|
cache["favoriteModelIds"] = json
|
||||||
|
DatabaseService.shared.setSetting(key: "favoriteModelIds", value: json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavoriteModel(_ id: String) {
|
||||||
|
var favs = favoriteModelIds
|
||||||
|
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
|
||||||
|
favoriteModelIds = favs
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Anytype MCP Settings
|
// MARK: - Anytype MCP Settings
|
||||||
|
|
||||||
var anytypeMcpEnabled: Bool {
|
var anytypeMcpEnabled: Bool {
|
||||||
@@ -475,6 +519,46 @@ class SettingsService {
|
|||||||
return !key.isEmpty
|
return !key.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Jarvis Settings
|
||||||
|
|
||||||
|
var jarvisEnabled: Bool {
|
||||||
|
get { cache["jarvisEnabled"] == "true" }
|
||||||
|
set {
|
||||||
|
cache["jarvisEnabled"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "jarvisEnabled", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var jarvisURL: String {
|
||||||
|
get { cache["jarvisURL"] ?? "" }
|
||||||
|
set {
|
||||||
|
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
cache.removeValue(forKey: "jarvisURL")
|
||||||
|
DatabaseService.shared.deleteSetting(key: "jarvisURL")
|
||||||
|
} else {
|
||||||
|
cache["jarvisURL"] = trimmed
|
||||||
|
DatabaseService.shared.setSetting(key: "jarvisURL", value: trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var jarvisAPIKey: String? {
|
||||||
|
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.jarvisAPIKey) }
|
||||||
|
set {
|
||||||
|
if let value = newValue, !value.isEmpty {
|
||||||
|
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.jarvisAPIKey, value: value)
|
||||||
|
} else {
|
||||||
|
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.jarvisAPIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var jarvisConfigured: Bool {
|
||||||
|
guard let key = jarvisAPIKey else { return false }
|
||||||
|
return !jarvisURL.isEmpty && !key.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Bash Execution Settings
|
// MARK: - Bash Execution Settings
|
||||||
|
|
||||||
var bashEnabled: Bool {
|
var bashEnabled: 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class ChatViewModel {
|
|||||||
var showHistory: Bool = false
|
var showHistory: Bool = false
|
||||||
var showShortcuts: Bool = false
|
var showShortcuts: Bool = false
|
||||||
var showSkills: Bool = false
|
var showSkills: Bool = false
|
||||||
|
var showJarvis: Bool = false
|
||||||
var modelInfoTarget: ModelInfo? = nil
|
var modelInfoTarget: ModelInfo? = nil
|
||||||
var commandHistory: [String] = []
|
var commandHistory: [String] = []
|
||||||
var historyIndex: Int = 0
|
var historyIndex: Int = 0
|
||||||
@@ -417,11 +418,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
let newProvider = inferProvider(from: model.id) ?? currentProvider
|
let newProvider = inferProvider(from: model.id) ?? currentProvider
|
||||||
selectedModel = model
|
selectedModel = model
|
||||||
currentProvider = newProvider
|
currentProvider = newProvider
|
||||||
settings.defaultModel = model.id
|
|
||||||
settings.defaultProvider = newProvider
|
|
||||||
MCPService.shared.resetBashSessionApproval()
|
MCPService.shared.resetBashSessionApproval()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
|
||||||
|
|
||||||
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
||||||
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
||||||
if modelId.contains("/") { return .openrouter }
|
if modelId.contains("/") { return .openrouter }
|
||||||
@@ -742,6 +743,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
case "/skills":
|
case "/skills":
|
||||||
showSkills = true
|
showSkills = true
|
||||||
|
|
||||||
|
case "/jarvis":
|
||||||
|
showJarvis = true
|
||||||
|
|
||||||
case "/mcp":
|
case "/mcp":
|
||||||
handleMCPCommand(args: args)
|
handleMCPCommand(args: args)
|
||||||
|
|
||||||
@@ -930,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)
|
||||||
}
|
}
|
||||||
@@ -997,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)
|
||||||
}
|
}
|
||||||
@@ -1309,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 }
|
||||||
|
|
||||||
@@ -1525,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
|
||||||
}
|
}
|
||||||
@@ -1655,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))
|
||||||
@@ -2176,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)
|
||||||
@@ -106,6 +110,9 @@ struct ChatView: View {
|
|||||||
.sheet(isPresented: $viewModel.showSkills) {
|
.sheet(isPresented: $viewModel.showSkills) {
|
||||||
AgentSkillsView()
|
AgentSkillsView()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $viewModel.showJarvis) {
|
||||||
|
JarvisView()
|
||||||
|
}
|
||||||
.sheet(item: Binding(
|
.sheet(item: Binding(
|
||||||
get: { MCPService.shared.pendingBashCommand },
|
get: { MCPService.shared.pendingBashCommand },
|
||||||
set: { _ in }
|
set: { _ in }
|
||||||
|
|||||||
@@ -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
-146
@@ -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,139 +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: 12) {
|
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")
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Status indicators
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if model?.capabilities.imageGeneration == true {
|
|
||||||
StatusPill(icon: "paintbrush", label: "Image", color: .purple)
|
|
||||||
}
|
|
||||||
if onlineMode {
|
|
||||||
StatusPill(icon: "globe", label: "Online", color: .green)
|
|
||||||
}
|
|
||||||
if mcpEnabled {
|
|
||||||
StatusPill(icon: "folder", label: "MCP", color: .blue)
|
|
||||||
}
|
|
||||||
if settings.syncEnabled && settings.syncAutoSave {
|
|
||||||
SyncStatusPill()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Divider between status and stats
|
// Center: conversation title (macOS document-title style)
|
||||||
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) {
|
conversationTitle
|
||||||
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()
|
||||||
@@ -169,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
|
||||||
@@ -273,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 }
|
||||||
)
|
)
|
||||||
|
|||||||
+127
-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,24 +24,35 @@
|
|||||||
|
|
||||||
|
|
||||||
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> = [
|
||||||
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
|
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
|
||||||
"/settings", "/credits", "/list", "/load", "/shortcuts", "/skills",
|
"/settings", "/credits", "/list", "/load", "/shortcuts", "/skills", "/jarvis",
|
||||||
"/memory on", "/memory off", "/online on", "/online off",
|
"/memory on", "/memory off", "/online on", "/online off",
|
||||||
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
||||||
"/mcp write on", "/mcp write off",
|
"/mcp write on", "/mcp write off",
|
||||||
@@ -56,110 +67,98 @@ struct InputBar: View {
|
|||||||
CommandSuggestionsView(
|
CommandSuggestionsView(
|
||||||
searchText: text,
|
searchText: text,
|
||||||
selectedIndex: selectedSuggestionIndex,
|
selectedIndex: selectedSuggestionIndex,
|
||||||
onSelect: { command in
|
onSelect: selectCommand
|
||||||
selectCommand(command)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
.frame(width: 400)
|
.frame(width: 400)
|
||||||
.frame(maxHeight: 200)
|
.frame(maxHeight: 200)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.leading, 96) // Align with input box (status badges + spacing)
|
.padding(.leading, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input area
|
// Drag-to-resize handle
|
||||||
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(
|
||||||
@@ -167,10 +166,9 @@ struct InputBar: View {
|
|||||||
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
|
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Action buttons
|
// Send / stop + attach buttons
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// File attach button
|
|
||||||
Button(action: pickFile) {
|
Button(action: pickFile) {
|
||||||
Image(systemName: "paperclip")
|
Image(systemName: "paperclip")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
@@ -209,21 +207,47 @@ struct InputBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Drag handle
|
||||||
|
|
||||||
|
private var dragHandle: some View {
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 8)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.overlay {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.secondary.opacity(0.25))
|
||||||
|
.frame(width: 36, height: 3)
|
||||||
|
}
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 1)
|
||||||
|
.onChanged { value in
|
||||||
|
let proposed = dragStartHeight - value.translation.height
|
||||||
|
inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed))
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
dragStartHeight = inputHeight
|
||||||
|
settings.inputBarHeight = Double(inputHeight)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#if os(macOS)
|
||||||
|
.onHover { hovering in
|
||||||
|
if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func selectCommand(_ command: String) {
|
private func selectCommand(_ command: String) {
|
||||||
showCommandDropdown = false
|
showCommandDropdown = false
|
||||||
if Self.immediateCommands.contains(command) {
|
if Self.immediateCommands.contains(command) {
|
||||||
// Execute immediately
|
|
||||||
text = command
|
text = command
|
||||||
onSend()
|
onSend()
|
||||||
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
|
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
|
||||||
if shortcut.needsInput {
|
text = shortcut.needsInput ? command + " " : command
|
||||||
text = command + " "
|
if !shortcut.needsInput { onSend() }
|
||||||
} else {
|
|
||||||
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
|
||||||
@@ -279,6 +281,7 @@ struct CommandSuggestionsView: View {
|
|||||||
("/retry", "Retry last message"),
|
("/retry", "Retry last message"),
|
||||||
("/shortcuts", "Manage your prompt shortcuts"),
|
("/shortcuts", "Manage your prompt shortcuts"),
|
||||||
("/skills", "Manage your agent skills"),
|
("/skills", "Manage your agent skills"),
|
||||||
|
("/jarvis", "Open Jarvis agent manager"),
|
||||||
("/memory on", "Enable conversation memory"),
|
("/memory on", "Enable conversation memory"),
|
||||||
("/memory off", "Disable conversation memory"),
|
("/memory off", "Disable conversation memory"),
|
||||||
("/online on", "Enable web search"),
|
("/online on", "Enable web search"),
|
||||||
@@ -303,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)] {
|
||||||
@@ -343,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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,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() {
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ private let helpCategories: [CommandCategory] = [
|
|||||||
examples: ["/mcp write on", "/mcp write off"]
|
examples: ["/mcp write on", "/mcp write off"]
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
CommandCategory(name: "Integrations", icon: "server.rack", commands: [
|
||||||
|
CommandDetail(
|
||||||
|
command: "/jarvis",
|
||||||
|
brief: "Open Jarvis agent manager",
|
||||||
|
detail: "Opens the Jarvis panel where you can list, create, run, and monitor agents on your Jarvis (oAI-Web) server, and view usage and cost statistics. Configure the server URL and API key in Settings → Jarvis.",
|
||||||
|
examples: ["/jarvis"]
|
||||||
|
),
|
||||||
|
]),
|
||||||
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
|
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
|
||||||
CommandDetail(
|
CommandDetail(
|
||||||
command: "/config",
|
command: "/config",
|
||||||
|
|||||||
@@ -0,0 +1,866 @@
|
|||||||
|
//
|
||||||
|
// JarvisView.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Main modal for managing Jarvis (oAI-Web) agents and usage.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Editor context (avoids .sheet timing bug)
|
||||||
|
|
||||||
|
private struct AgentEditContext: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let agent: JarvisAgent? // nil → new
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JarvisView
|
||||||
|
|
||||||
|
struct JarvisView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
@State private var agents: [JarvisAgent] = []
|
||||||
|
@State private var runs: [String: [JarvisAgentRun]] = [:]
|
||||||
|
@State private var usageStats: [JarvisUsageStat] = []
|
||||||
|
@State private var credits: JarvisCreditsResponse? = nil
|
||||||
|
@State private var queueStatus: JarvisQueueStatus? = nil
|
||||||
|
@State private var isLoadingAgents = false
|
||||||
|
@State private var isLoadingUsage = false
|
||||||
|
@State private var selectedAgent: JarvisAgent? = nil
|
||||||
|
@State private var editContext: AgentEditContext? = nil
|
||||||
|
@State private var errorMessage: String? = nil
|
||||||
|
@State private var actionInProgress: Set<String> = []
|
||||||
|
|
||||||
|
private let service = JarvisService.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
Label("Jarvis", systemImage: "server.rack")
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
Spacer()
|
||||||
|
if let qs = queueStatus {
|
||||||
|
queueStatusBadge(qs)
|
||||||
|
}
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.title2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.keyboardShortcut(.escape, modifiers: [])
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
// Tab picker
|
||||||
|
Picker("", selection: $selectedTab) {
|
||||||
|
Text("Agents").tag(0)
|
||||||
|
Text("Usage").tag(1)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if let err = errorMessage {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
|
||||||
|
Text(err).font(.callout).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button("Dismiss") { errorMessage = nil }.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch selectedTab {
|
||||||
|
case 0: agentsTab
|
||||||
|
default: usageTab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 840, idealWidth: 960, minHeight: 560, idealHeight: 720)
|
||||||
|
.task { await loadAll() }
|
||||||
|
.sheet(item: $editContext) { ctx in
|
||||||
|
JarvisAgentEditorSheet(agent: ctx.agent, onSave: { input in
|
||||||
|
await saveAgent(existing: ctx.agent, input: input)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Agents Tab
|
||||||
|
|
||||||
|
private var agentsTab: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
agentList
|
||||||
|
} detail: {
|
||||||
|
if let agent = selectedAgent {
|
||||||
|
agentDetailView(agent)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView("Select an Agent", systemImage: "server.rack",
|
||||||
|
description: Text("Choose an agent from the list to view details and run history"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationSplitViewStyle(.balanced)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var agentList: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Toolbar
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
editContext = AgentEditContext(agent: nil)
|
||||||
|
} label: {
|
||||||
|
Label("New Agent", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isLoadingAgents {
|
||||||
|
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button { Task { await loadAgents() } } label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Refresh agents")
|
||||||
|
|
||||||
|
// Queue control
|
||||||
|
if let qs = queueStatus {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if qs.paused == true { try await service.resumeAll() }
|
||||||
|
else { try await service.pauseAll() }
|
||||||
|
await loadQueueStatus()
|
||||||
|
} catch { errorMessage = error.localizedDescription }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: qs.paused == true ? "play.fill" : "pause.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help(qs.paused == true ? "Resume queue" : "Pause queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if agents.isEmpty && !isLoadingAgents {
|
||||||
|
ContentUnavailableView("No Agents", systemImage: "server.rack")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
List(agents, id: \.id, selection: $selectedAgent) { agent in
|
||||||
|
agentRow(agent)
|
||||||
|
.tag(agent)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func agentRow(_ agent: JarvisAgent) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Status dot
|
||||||
|
Circle()
|
||||||
|
.fill(agent.isRunning == true ? Color.green : Color.secondary.opacity(0.4))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(agent.name)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
if let schedule = agent.schedule, !schedule.isEmpty {
|
||||||
|
Text(schedule)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { agent.enabled },
|
||||||
|
set: { _ in Task { await toggleAgent(agent) } }
|
||||||
|
))
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.controlSize(.mini)
|
||||||
|
.labelsHidden()
|
||||||
|
|
||||||
|
// Run / Stop button
|
||||||
|
if agent.isRunning == true {
|
||||||
|
Button {
|
||||||
|
Task { await stopAgent(agent) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(actionInProgress.contains(agent.id))
|
||||||
|
.help("Stop agent")
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
Task { await runAgent(agent) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: actionInProgress.contains(agent.id) ? "ellipsis" : "play.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(actionInProgress.contains(agent.id) || !agent.enabled)
|
||||||
|
.help("Run agent now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func agentDetailView(_ agent: JarvisAgent) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Agent info header
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(agent.isRunning == true ? Color.green : Color.secondary.opacity(0.4))
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text(agent.name)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
if !agent.enabled {
|
||||||
|
Text("Disabled")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
|
.background(Color.orange.opacity(0.15))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(agent.model)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
editContext = AgentEditContext(agent: agent)
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await deleteAgent(agent) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !agent.description.isEmpty {
|
||||||
|
Text(agent.description)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let schedule = agent.schedule, !schedule.isEmpty {
|
||||||
|
Label(schedule, systemImage: "clock")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Prompt preview
|
||||||
|
DisclosureGroup("Prompt") {
|
||||||
|
ScrollView {
|
||||||
|
Text(agent.prompt)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 120)
|
||||||
|
.background(Color.gray.opacity(0.06))
|
||||||
|
.cornerRadius(6)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Run history
|
||||||
|
Text("Run History")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
let agentRuns = runs[agent.id] ?? []
|
||||||
|
if agentRuns.isEmpty {
|
||||||
|
Text("No runs yet")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
|
} else {
|
||||||
|
List(agentRuns) { run in
|
||||||
|
RunHistoryRow(run: run)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: agent.id) {
|
||||||
|
await loadRuns(for: agent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Usage Tab
|
||||||
|
|
||||||
|
private var usageTab: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Credits card
|
||||||
|
if let creds = credits, let balance = creds.remainingBalance {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("OpenRouter Balance")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "$%.4f", balance))
|
||||||
|
.font(.system(size: 22, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(balance < 1.0 ? .orange : .primary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let total = creds.totalCredits {
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("Total Credits")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "$%.2f", total))
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let used = creds.totalUsage {
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("Total Used")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "$%.4f", used))
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.blue.opacity(0.06))
|
||||||
|
.cornerRadius(10)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoadingUsage {
|
||||||
|
ProgressView("Loading usage…")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if usageStats.isEmpty {
|
||||||
|
ContentUnavailableView("No Usage Data", systemImage: "chart.bar",
|
||||||
|
description: Text("Run some agents to see usage statistics"))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
// Header row
|
||||||
|
HStack {
|
||||||
|
Text("Agent")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text("Runs")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
Text("Input")
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
Text("Output")
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
Text("Cost")
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(usageStats) { stat in
|
||||||
|
usageRow(stat)
|
||||||
|
Divider().padding(.leading, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals row
|
||||||
|
let totalRuns = usageStats.compactMap(\.runCount).reduce(0, +)
|
||||||
|
let totalInput = usageStats.compactMap(\.totalInputTokens).reduce(0, +)
|
||||||
|
let totalOutput = usageStats.compactMap(\.totalOutputTokens).reduce(0, +)
|
||||||
|
let totalCost = usageStats.compactMap(\.totalCostUsd).reduce(0, +)
|
||||||
|
HStack {
|
||||||
|
Text("Total")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text("\(totalRuns)")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(formatTokens(totalInput))
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(formatTokens(totalOutput))
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(String(format: "$%.4f", totalCost))
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.gray.opacity(0.07))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button { Task { await loadUsage() } } label: {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func usageRow(_ stat: JarvisUsageStat) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(stat.displayName)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(stat.runCount ?? 0)")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(formatTokens(stat.totalInputTokens ?? 0))
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(formatTokens(stat.totalOutputTokens ?? 0))
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(stat.totalCostUsd.map { String(format: "$%.4f", $0) } ?? "—")
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
}
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Queue Status Badge
|
||||||
|
|
||||||
|
private func queueStatusBadge(_ qs: JarvisQueueStatus) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if qs.paused == true {
|
||||||
|
Label("Paused", systemImage: "pause.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
} else if let running = qs.runningCount, running > 0 {
|
||||||
|
Label("^[\(running) running](inflect: true)", systemImage: "circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Label("Idle", systemImage: "checkmark.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.cornerRadius(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Loading
|
||||||
|
|
||||||
|
private func loadAll() async {
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
group.addTask { await self.loadAgents() }
|
||||||
|
group.addTask { await self.loadUsage() }
|
||||||
|
group.addTask { await self.loadQueueStatus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadAgents() async {
|
||||||
|
isLoadingAgents = true
|
||||||
|
do {
|
||||||
|
let fetched = try await service.listAgents()
|
||||||
|
agents = fetched
|
||||||
|
if let current = selectedAgent {
|
||||||
|
selectedAgent = fetched.first(where: { $0.id == current.id })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoadingAgents = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadRuns(for agent: JarvisAgent) async {
|
||||||
|
do {
|
||||||
|
let fetched = try await service.agentRuns(id: agent.id)
|
||||||
|
runs[agent.id] = fetched
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadUsage() async {
|
||||||
|
isLoadingUsage = true
|
||||||
|
do {
|
||||||
|
async let statsTask = service.usage()
|
||||||
|
async let creditsTask = service.credits()
|
||||||
|
usageStats = try await statsTask
|
||||||
|
credits = try? await creditsTask
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoadingUsage = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadQueueStatus() async {
|
||||||
|
queueStatus = try? await service.queueStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Agent Actions
|
||||||
|
|
||||||
|
private func toggleAgent(_ agent: JarvisAgent) async {
|
||||||
|
do {
|
||||||
|
let updated = try await service.toggleAgent(id: agent.id)
|
||||||
|
updateAgent(updated)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAgent(_ agent: JarvisAgent) async {
|
||||||
|
actionInProgress.insert(agent.id)
|
||||||
|
do {
|
||||||
|
try await service.runAgent(id: agent.id)
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
await loadAgents()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
actionInProgress.remove(agent.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAgent(_ agent: JarvisAgent) async {
|
||||||
|
do {
|
||||||
|
try await service.stopAgent(id: agent.id)
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
await loadAgents()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteAgent(_ agent: JarvisAgent) async {
|
||||||
|
do {
|
||||||
|
try await service.deleteAgent(id: agent.id)
|
||||||
|
agents.removeAll { $0.id == agent.id }
|
||||||
|
if selectedAgent?.id == agent.id { selectedAgent = nil }
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveAgent(existing: JarvisAgent?, input: JarvisAgentInput) async {
|
||||||
|
do {
|
||||||
|
let result: JarvisAgent
|
||||||
|
if let existing {
|
||||||
|
result = try await service.updateAgent(id: existing.id, input)
|
||||||
|
} else {
|
||||||
|
result = try await service.createAgent(input)
|
||||||
|
}
|
||||||
|
updateAgent(result)
|
||||||
|
selectedAgent = result
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAgent(_ updated: JarvisAgent) {
|
||||||
|
if let idx = agents.firstIndex(where: { $0.id == updated.id }) {
|
||||||
|
agents[idx] = updated
|
||||||
|
} else {
|
||||||
|
agents.append(updated)
|
||||||
|
}
|
||||||
|
if selectedAgent?.id == updated.id {
|
||||||
|
selectedAgent = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Formatting
|
||||||
|
|
||||||
|
private func formatTokens(_ n: Int) -> String {
|
||||||
|
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
|
||||||
|
if n >= 1_000 { return String(format: "%.1fK", Double(n) / 1_000) }
|
||||||
|
return "\(n)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Run History Row
|
||||||
|
|
||||||
|
private struct RunHistoryRow: View {
|
||||||
|
let run: JarvisAgentRun
|
||||||
|
@State private var expanded = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
statusIcon
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(run.formattedStarted)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
if let dur = run.formattedDuration {
|
||||||
|
Text("· \(dur)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if run.totalTokens > 0 {
|
||||||
|
Text("^[\(run.totalTokens) token](inflect: true)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let cost = run.costUsd, cost > 0 {
|
||||||
|
Text(String(format: "$%.5f", cost))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if run.output != nil || run.error != nil {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expanded {
|
||||||
|
if let err = run.error {
|
||||||
|
Text(err)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.red.opacity(0.06))
|
||||||
|
.cornerRadius(6)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
} else if let out = run.output {
|
||||||
|
Text(out)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.gray.opacity(0.07))
|
||||||
|
.cornerRadius(6)
|
||||||
|
.lineLimit(20)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusIcon: some View {
|
||||||
|
switch run.status {
|
||||||
|
case "running":
|
||||||
|
ProgressView().scaleEffect(0.6).frame(width: 14, height: 14)
|
||||||
|
case "completed":
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
case "failed":
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
case "stopped":
|
||||||
|
Image(systemName: "stop.circle.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
default:
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Agent Editor Sheet
|
||||||
|
|
||||||
|
struct JarvisAgentEditorSheet: View {
|
||||||
|
let agent: JarvisAgent?
|
||||||
|
let onSave: (JarvisAgentInput) async -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State private var name: String
|
||||||
|
@State private var description: String
|
||||||
|
@State private var model: String
|
||||||
|
@State private var prompt: String
|
||||||
|
@State private var schedule: String
|
||||||
|
@State private var enabled: Bool
|
||||||
|
@State private var maxToolCalls: String
|
||||||
|
@State private var isSaving = false
|
||||||
|
|
||||||
|
init(agent: JarvisAgent?, onSave: @escaping (JarvisAgentInput) async -> Void) {
|
||||||
|
self.agent = agent
|
||||||
|
self.onSave = onSave
|
||||||
|
_name = State(initialValue: agent?.name ?? "")
|
||||||
|
_description = State(initialValue: agent?.description ?? "")
|
||||||
|
_model = State(initialValue: agent?.model ?? "")
|
||||||
|
_prompt = State(initialValue: agent?.prompt ?? "")
|
||||||
|
_schedule = State(initialValue: agent?.schedule ?? "")
|
||||||
|
_enabled = State(initialValue: agent?.enabled ?? true)
|
||||||
|
_maxToolCalls = State(initialValue: agent.flatMap(\.maxToolCalls).map(String.init) ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && !model.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
HStack {
|
||||||
|
Text(agent == nil ? "New Agent" : "Edit Agent")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
Spacer()
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.title3).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Group {
|
||||||
|
editorField("Name") {
|
||||||
|
TextField("Agent name", text: $name)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
editorField("Description") {
|
||||||
|
TextField("Optional description", text: $description)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
editorField("Model") {
|
||||||
|
TextField("e.g. anthropic/claude-sonnet-4-5", text: $model)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
editorField("Schedule") {
|
||||||
|
TextField("Cron expression (e.g. 0 * * * *)", text: $schedule)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
editorField("Max Tool Calls") {
|
||||||
|
TextField("Leave blank for default", text: $maxToolCalls)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 160)
|
||||||
|
}
|
||||||
|
editorField("Enabled") {
|
||||||
|
Toggle("", isOn: $enabled)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Prompt")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
TextEditor(text: $prompt)
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
.frame(minHeight: 160)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(Color.gray.opacity(0.3))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.escape, modifiers: [])
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
isSaving = true
|
||||||
|
let input = JarvisAgentInput(
|
||||||
|
name: name.trimmingCharacters(in: .whitespaces),
|
||||||
|
prompt: prompt,
|
||||||
|
model: model.trimmingCharacters(in: .whitespaces),
|
||||||
|
description: description,
|
||||||
|
enabled: enabled,
|
||||||
|
schedule: schedule.isEmpty ? nil : schedule,
|
||||||
|
maxToolCalls: Int(maxToolCalls)
|
||||||
|
)
|
||||||
|
await onSave(input)
|
||||||
|
isSaving = false
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if isSaving {
|
||||||
|
ProgressView().scaleEffect(0.8).frame(width: 16, height: 16)
|
||||||
|
} else {
|
||||||
|
Text(agent == nil ? "Create" : "Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!isValid || isSaving)
|
||||||
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 560, idealWidth: 620, minHeight: 560, idealHeight: 700)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editorField<Content: View>(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.frame(width: 100, alignment: .trailing)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ struct ModelInfoView: View {
|
|||||||
let model: ModelInfo
|
let model: ModelInfo
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@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) {
|
||||||
@@ -37,6 +39,15 @@ struct ModelInfoView: View {
|
|||||||
Text("Model Info")
|
Text("Model Info")
|
||||||
.font(.system(size: 18, weight: .bold))
|
.font(.system(size: 18, weight: .bold))
|
||||||
Spacer()
|
Spacer()
|
||||||
|
let isFav = settings.favoriteModelIds.contains(model.id)
|
||||||
|
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
||||||
|
Image(systemName: isFav ? "star.fill" : "star")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundColor(isFav ? .yellow : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
||||||
|
.padding(.trailing, 8)
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
@@ -68,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)
|
||||||
}
|
}
|
||||||
@@ -135,6 +156,32 @@ struct ModelInfoView: View {
|
|||||||
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
|
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Categories (if any)
|
||||||
|
if !model.categories.isEmpty {
|
||||||
|
Divider()
|
||||||
|
sectionHeader("Categories")
|
||||||
|
FlowLayout(spacing: 8) {
|
||||||
|
ForEach(model.categories, id: \.rawValue) { cat in
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: cat.systemImage)
|
||||||
|
.font(.caption2)
|
||||||
|
Text(LocalizedStringKey(cat.rawValue))
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(cat.color.opacity(0.12))
|
||||||
|
.foregroundColor(cat.color)
|
||||||
|
.cornerRadius(6)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(cat.color.opacity(0.35), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
|
||||||
// Architecture (if available)
|
// Architecture (if available)
|
||||||
if let arch = model.architecture {
|
if let arch = model.architecture {
|
||||||
Divider()
|
Divider()
|
||||||
@@ -228,6 +275,50 @@ struct ModelInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Flow Layout
|
||||||
|
|
||||||
|
struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 8
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
||||||
|
let rows = rows(for: subviews, width: proposal.width ?? .infinity)
|
||||||
|
let height = rows.map { row in
|
||||||
|
row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
|
||||||
|
}.reduce(0, +) + CGFloat(max(0, rows.count - 1)) * spacing
|
||||||
|
return CGSize(width: proposal.width ?? 0, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
|
||||||
|
let rows = rows(for: subviews, width: bounds.width)
|
||||||
|
var y = bounds.minY
|
||||||
|
for row in rows {
|
||||||
|
var x = bounds.minX
|
||||||
|
let rowH = row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
|
||||||
|
for sub in row {
|
||||||
|
let size = sub.sizeThatFits(.unspecified)
|
||||||
|
sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||||
|
x += size.width + spacing
|
||||||
|
}
|
||||||
|
y += rowH + spacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rows(for subviews: Subviews, width: CGFloat) -> [[LayoutSubview]] {
|
||||||
|
var rows: [[LayoutSubview]] = [[]]
|
||||||
|
var rowWidth: CGFloat = 0
|
||||||
|
for sub in subviews {
|
||||||
|
let w = sub.sizeThatFits(.unspecified).width
|
||||||
|
if rowWidth + w > width, !rows.last!.isEmpty {
|
||||||
|
rows.append([])
|
||||||
|
rowWidth = 0
|
||||||
|
}
|
||||||
|
rows[rows.count - 1].append(sub)
|
||||||
|
rowWidth += w + spacing
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ModelInfoView(model: ModelInfo(
|
ModelInfoView(model: ModelInfo(
|
||||||
id: "anthropic/claude-sonnet-4",
|
id: "anthropic/claude-sonnet-4",
|
||||||
|
|||||||
@@ -37,9 +37,17 @@ struct ModelSelectorView: View {
|
|||||||
@State private var filterOnline = false
|
@State private var filterOnline = false
|
||||||
@State private var filterImageGen = false
|
@State private var filterImageGen = false
|
||||||
@State private var filterThinking = false
|
@State private var filterThinking = false
|
||||||
|
@State private var filterFavorites = false
|
||||||
|
@State private var selectedCategory: ModelCategory? = nil
|
||||||
|
@State private var showCategoryPicker = false
|
||||||
@State private var keyboardIndex: Int = -1
|
@State private var keyboardIndex: Int = -1
|
||||||
@State private var sortOrder: ModelSortOrder = .default
|
@State private var sortOrder: ModelSortOrder = .default
|
||||||
@State private var selectedInfoModel: ModelInfo? = nil
|
@State private var selectedInfoModel: ModelInfo? = nil
|
||||||
|
@Bindable private var settings = SettingsService.shared
|
||||||
|
|
||||||
|
private var categoriesWithModels: Set<ModelCategory> {
|
||||||
|
Set(models.flatMap(\.categories))
|
||||||
|
}
|
||||||
|
|
||||||
private var filteredModels: [ModelInfo] {
|
private var filteredModels: [ModelInfo] {
|
||||||
let q = searchText.lowercased()
|
let q = searchText.lowercased()
|
||||||
@@ -54,13 +62,21 @@ struct ModelSelectorView: View {
|
|||||||
let matchesOnline = !filterOnline || model.capabilities.online
|
let matchesOnline = !filterOnline || model.capabilities.online
|
||||||
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
||||||
let matchesThinking = !filterThinking || model.capabilities.thinking
|
let matchesThinking = !filterThinking || model.capabilities.thinking
|
||||||
|
let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
|
||||||
|
let matchesCategory = selectedCategory == nil || model.categories.contains(selectedCategory!)
|
||||||
|
|
||||||
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking
|
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites && matchesCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let favIds = settings.favoriteModelIds
|
||||||
switch sortOrder {
|
switch sortOrder {
|
||||||
case .default:
|
case .default:
|
||||||
return filtered
|
return filtered.sorted { a, b in
|
||||||
|
let aFav = favIds.contains(a.id)
|
||||||
|
let bFav = favIds.contains(b.id)
|
||||||
|
if aFav != bFav { return aFav }
|
||||||
|
return false
|
||||||
|
}
|
||||||
case .priceLowHigh:
|
case .priceLowHigh:
|
||||||
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
|
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
|
||||||
case .priceHighLow:
|
case .priceHighLow:
|
||||||
@@ -91,6 +107,53 @@ struct ModelSelectorView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Category picker (only shown when at least one category has models)
|
||||||
|
if !categoriesWithModels.isEmpty {
|
||||||
|
Button(action: { showCategoryPicker.toggle() }) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let cat = selectedCategory {
|
||||||
|
Circle().fill(cat.color).frame(width: 7, height: 7)
|
||||||
|
Text(LocalizedStringKey(cat.rawValue))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
Text("Category")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(selectedCategory != nil ? selectedCategory!.color.opacity(0.18) : Color.gray.opacity(0.1))
|
||||||
|
.foregroundColor(selectedCategory != nil ? selectedCategory!.color : .secondary)
|
||||||
|
.cornerRadius(6)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(selectedCategory != nil ? selectedCategory!.color.opacity(0.4) : Color.clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Filter by category")
|
||||||
|
.popover(isPresented: $showCategoryPicker, arrowEdge: .bottom) {
|
||||||
|
CategoryPickerPopover(
|
||||||
|
categoriesWithModels: categoriesWithModels,
|
||||||
|
selectedCategory: $selectedCategory,
|
||||||
|
onSelect: { keyboardIndex = -1 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} // end if !categoriesWithModels.isEmpty
|
||||||
|
|
||||||
|
// Favorites filter star
|
||||||
|
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
||||||
|
Image(systemName: filterFavorites ? "star.fill" : "star")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(filterFavorites ? Color.yellow.opacity(0.25) : Color.gray.opacity(0.1))
|
||||||
|
.foregroundColor(filterFavorites ? .yellow : .secondary)
|
||||||
|
.cornerRadius(6)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Show favorites only")
|
||||||
|
|
||||||
// Sort menu
|
// Sort menu
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
|
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
|
||||||
@@ -140,7 +203,9 @@ struct ModelSelectorView: View {
|
|||||||
model: model,
|
model: model,
|
||||||
isSelected: model.id == selectedModel?.id,
|
isSelected: model.id == selectedModel?.id,
|
||||||
isKeyboardHighlighted: index == keyboardIndex,
|
isKeyboardHighlighted: index == keyboardIndex,
|
||||||
|
isFavorite: settings.favoriteModelIds.contains(model.id),
|
||||||
onSelect: { onSelect(model) },
|
onSelect: { onSelect(model) },
|
||||||
|
onFavorite: { settings.toggleFavoriteModel(model.id) },
|
||||||
onInfo: { selectedInfoModel = model }
|
onInfo: { selectedInfoModel = model }
|
||||||
)
|
)
|
||||||
.id(model.id)
|
.id(model.id)
|
||||||
@@ -156,7 +221,7 @@ struct ModelSelectorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 600, minHeight: 500)
|
.frame(minWidth: 740, minHeight: 500)
|
||||||
.navigationTitle("Select Model")
|
.navigationTitle("Select Model")
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onKeyPress(.downArrow) {
|
.onKeyPress(.downArrow) {
|
||||||
@@ -231,6 +296,7 @@ struct FilterToggle: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(icon)
|
Text(icon)
|
||||||
Text(label)
|
Text(label)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
@@ -240,6 +306,68 @@ struct FilterToggle: View {
|
|||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Picker Popover
|
||||||
|
|
||||||
|
struct CategoryPickerPopover: View {
|
||||||
|
let categoriesWithModels: Set<ModelCategory>
|
||||||
|
@Binding var selectedCategory: ModelCategory?
|
||||||
|
let onSelect: () -> Void
|
||||||
|
|
||||||
|
private let columns = [GridItem(.adaptive(minimum: 130), spacing: 8)]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Filter by Category")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
if selectedCategory != nil {
|
||||||
|
Button("Clear") {
|
||||||
|
selectedCategory = nil
|
||||||
|
onSelect()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyVGrid(columns: columns, spacing: 8) {
|
||||||
|
ForEach(ModelCategory.allCases.filter { categoriesWithModels.contains($0) }, id: \.rawValue) { cat in
|
||||||
|
let isSelected = selectedCategory == cat
|
||||||
|
Button {
|
||||||
|
selectedCategory = isSelected ? nil : cat
|
||||||
|
onSelect()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: cat.systemImage)
|
||||||
|
.font(.caption)
|
||||||
|
.frame(width: 14)
|
||||||
|
Text(LocalizedStringKey(cat.rawValue))
|
||||||
|
.font(.caption)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(isSelected ? cat.color.opacity(0.18) : Color.gray.opacity(0.08))
|
||||||
|
.foregroundColor(isSelected ? cat.color : .primary)
|
||||||
|
.cornerRadius(6)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(isSelected ? cat.color.opacity(0.45) : Color.clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(minWidth: 360)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,14 +377,25 @@ struct ModelRowView: View {
|
|||||||
let model: ModelInfo
|
let model: ModelInfo
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
var isKeyboardHighlighted: Bool = false
|
var isKeyboardHighlighted: Bool = false
|
||||||
|
var isFavorite: Bool = false
|
||||||
let onSelect: () -> Void
|
let onSelect: () -> Void
|
||||||
|
var onFavorite: (() -> Void)? = nil
|
||||||
let onInfo: () -> Void
|
let onInfo: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
// Selectable main content
|
// Selectable main content
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack(spacing: 6) {
|
||||||
|
if let onFavorite {
|
||||||
|
Button(action: onFavorite) {
|
||||||
|
Image(systemName: isFavorite ? "star.fill" : "star")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(isFavorite ? .yellow : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(isFavorite ? "Remove from favorites" : "Add to favorites")
|
||||||
|
}
|
||||||
Text(model.name)
|
Text(model.name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(isSelected ? .blue : .primary)
|
.foregroundColor(isSelected ? .blue : .primary)
|
||||||
@@ -289,7 +428,7 @@ struct ModelRowView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onSelect() }
|
.onTapGesture { onSelect() }
|
||||||
|
|
||||||
// Right side: capabilities + info button
|
// Right side: capabilities + category dots + info button
|
||||||
VStack(alignment: .trailing, spacing: 6) {
|
VStack(alignment: .trailing, spacing: 6) {
|
||||||
// Capability icons
|
// Capability icons
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
@@ -300,6 +439,18 @@ struct ModelRowView: View {
|
|||||||
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
|
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Category dots
|
||||||
|
if !model.categories.isEmpty {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
ForEach(model.categories, id: \.rawValue) { cat in
|
||||||
|
Circle()
|
||||||
|
.fill(cat.color)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
.help(cat.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Info button
|
// Info button
|
||||||
Button(action: onInfo) {
|
Button(action: onInfo) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
|
|||||||
@@ -58,6 +58,23 @@ struct SettingsView: View {
|
|||||||
@State private var syncTestResult: String?
|
@State private var syncTestResult: String?
|
||||||
@State private var isSyncing = false
|
@State private var isSyncing = false
|
||||||
|
|
||||||
|
// Anytype state
|
||||||
|
@State private var anytypeAPIKey = ""
|
||||||
|
@State private var anytypeURL = ""
|
||||||
|
@State private var showAnytypeKey = false
|
||||||
|
@State private var isTestingAnytype = false
|
||||||
|
@State private var anytypeTestResult: String?
|
||||||
|
|
||||||
|
// Jarvis state
|
||||||
|
@State private var jarvisURL = ""
|
||||||
|
@State private var jarvisAPIKey = ""
|
||||||
|
@State private var showJarvisKey = false
|
||||||
|
@State private var isTestingJarvis = false
|
||||||
|
@State private var jarvisTestResult: String?
|
||||||
|
|
||||||
|
// Default model picker state
|
||||||
|
@State private var showDefaultModelPicker = false
|
||||||
|
|
||||||
// Paperless-NGX state
|
// Paperless-NGX state
|
||||||
@State private var paperlessURL = ""
|
@State private var paperlessURL = ""
|
||||||
@State private var paperlessToken = ""
|
@State private var paperlessToken = ""
|
||||||
@@ -136,14 +153,16 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
tabButton(2, icon: "paintbrush", label: "Appearance")
|
tabButton(2, icon: "paintbrush", label: "Appearance")
|
||||||
tabButton(3, icon: "slider.horizontal.3", label: "Advanced")
|
tabButton(3, icon: "slider.horizontal.3", label: "Advanced")
|
||||||
|
|
||||||
Divider().frame(height: 44).padding(.horizontal, 8)
|
Divider().frame(height: 44).padding(.horizontal, 4)
|
||||||
|
|
||||||
tabButton(6, icon: "command", label: "Shortcuts")
|
tabButton(6, icon: "command", label: "Shortcuts")
|
||||||
tabButton(7, icon: "brain", label: "Skills")
|
tabButton(7, icon: "brain", label: "Skills")
|
||||||
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
|
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
|
||||||
tabButton(5, icon: "envelope", label: "Email")
|
tabButton(5, icon: "envelope", label: "Email")
|
||||||
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
|
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
|
||||||
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
||||||
|
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
|
||||||
|
tabButton(11, icon: "server.rack", label: "Jarvis")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
@@ -173,6 +192,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
paperlessTab
|
paperlessTab
|
||||||
case 9:
|
case 9:
|
||||||
backupTab
|
backupTab
|
||||||
|
case 10:
|
||||||
|
anytypeTab
|
||||||
|
case 11:
|
||||||
|
jarvisTab
|
||||||
default:
|
default:
|
||||||
generalTab
|
generalTab
|
||||||
}
|
}
|
||||||
@@ -183,6 +206,20 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
|
|
||||||
}
|
}
|
||||||
.frame(minWidth: 860, idealWidth: 940, minHeight: 620, idealHeight: 760)
|
.frame(minWidth: 860, idealWidth: 940, minHeight: 620, idealHeight: 760)
|
||||||
|
.sheet(isPresented: $showDefaultModelPicker) {
|
||||||
|
ModelSelectorView(
|
||||||
|
models: chatViewModel?.availableModels ?? [],
|
||||||
|
selectedModel: chatViewModel?.availableModels.first(where: { $0.id == settingsService.defaultModel }),
|
||||||
|
onSelect: { model in
|
||||||
|
let provider = chatViewModel.flatMap { vm in
|
||||||
|
vm.inferProviderPublic(from: model.id)
|
||||||
|
} ?? settingsService.defaultProvider
|
||||||
|
settingsService.defaultModel = model.id
|
||||||
|
settingsService.defaultProvider = provider
|
||||||
|
showDefaultModelPicker = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showEmailLog) {
|
.sheet(isPresented: $showEmailLog) {
|
||||||
EmailLogView()
|
EmailLogView()
|
||||||
}
|
}
|
||||||
@@ -364,13 +401,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Model Settings")
|
sectionHeader("Model Settings")
|
||||||
formSection {
|
formSection {
|
||||||
row("Default Model ID") {
|
row("Default Model") {
|
||||||
TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
|
HStack(spacing: 8) {
|
||||||
get: { settingsService.defaultModel ?? "" },
|
let modelName: String = {
|
||||||
set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
|
if let id = settingsService.defaultModel {
|
||||||
))
|
return chatViewModel?.availableModels.first(where: { $0.id == id })?.name ?? id
|
||||||
.textFieldStyle(.roundedBorder)
|
}
|
||||||
.frame(width: 300)
|
return "Not set"
|
||||||
|
}()
|
||||||
|
Text(modelName)
|
||||||
|
.foregroundStyle(settingsService.defaultModel == nil ? .secondary : .primary)
|
||||||
|
.frame(maxWidth: 240, alignment: .leading)
|
||||||
|
Button("Choose…") { showDefaultModelPicker = true }
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
if settingsService.defaultModel != nil {
|
||||||
|
Button("Clear") {
|
||||||
|
settingsService.defaultModel = nil
|
||||||
|
settingsService.defaultProvider = .openrouter
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1909,6 +1961,218 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Anytype Tab
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var anytypeTab: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Anytype")
|
||||||
|
formSection {
|
||||||
|
row("Enable Anytype") {
|
||||||
|
Toggle("", isOn: $settingsService.anytypeMcpEnabled)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settingsService.anytypeMcpEnabled {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Connection")
|
||||||
|
formSection {
|
||||||
|
row("API URL") {
|
||||||
|
TextField("http://127.0.0.1:31009", text: $anytypeURL)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 300)
|
||||||
|
.onSubmit { settingsService.anytypeMcpURL = anytypeURL }
|
||||||
|
.onChange(of: anytypeURL) { _, new in settingsService.anytypeMcpURL = new }
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
row("API Key") {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if showAnytypeKey {
|
||||||
|
TextField("", text: $anytypeAPIKey)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
.onSubmit { settingsService.anytypeMcpAPIKey = anytypeAPIKey.isEmpty ? nil : anytypeAPIKey }
|
||||||
|
.onChange(of: anytypeAPIKey) { _, new in
|
||||||
|
settingsService.anytypeMcpAPIKey = new.isEmpty ? nil : new
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SecureField("", text: $anytypeAPIKey)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
.onSubmit { settingsService.anytypeMcpAPIKey = anytypeAPIKey.isEmpty ? nil : anytypeAPIKey }
|
||||||
|
.onChange(of: anytypeAPIKey) { _, new in
|
||||||
|
settingsService.anytypeMcpAPIKey = new.isEmpty ? nil : new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(showAnytypeKey ? "Hide" : "Show") {
|
||||||
|
showAnytypeKey.toggle()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: { Task { await testAnytypeConnection() } }) {
|
||||||
|
HStack {
|
||||||
|
if isTestingAnytype {
|
||||||
|
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
}
|
||||||
|
Text("Test Connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isTestingAnytype || !settingsService.anytypeMcpConfigured)
|
||||||
|
if let result = anytypeTestResult {
|
||||||
|
Text(result)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(result.hasPrefix("✓") ? .green : .red)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("How to get your API key:")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
Text("1. Open Anytype → Settings → Integrations")
|
||||||
|
Text("2. Create a new API key")
|
||||||
|
Text("3. Paste it above")
|
||||||
|
}
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
anytypeURL = settingsService.anytypeMcpURL
|
||||||
|
anytypeAPIKey = settingsService.anytypeMcpAPIKey ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Jarvis Tab
|
||||||
|
|
||||||
|
private var jarvisTab: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Jarvis")
|
||||||
|
formSection {
|
||||||
|
row("Enable Jarvis") {
|
||||||
|
Toggle("", isOn: $settingsService.jarvisEnabled)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settingsService.jarvisEnabled {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Connection")
|
||||||
|
formSection {
|
||||||
|
row("Server URL") {
|
||||||
|
TextField("https://jarvis.example.com", text: $jarvisURL)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 300)
|
||||||
|
.onSubmit { settingsService.jarvisURL = jarvisURL }
|
||||||
|
.onChange(of: jarvisURL) { _, new in settingsService.jarvisURL = new }
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
row("API Key") {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if showJarvisKey {
|
||||||
|
TextField("", text: $jarvisAPIKey)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
.onSubmit { settingsService.jarvisAPIKey = jarvisAPIKey.isEmpty ? nil : jarvisAPIKey }
|
||||||
|
.onChange(of: jarvisAPIKey) { _, new in
|
||||||
|
settingsService.jarvisAPIKey = new.isEmpty ? nil : new
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SecureField("", text: $jarvisAPIKey)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
.onSubmit { settingsService.jarvisAPIKey = jarvisAPIKey.isEmpty ? nil : jarvisAPIKey }
|
||||||
|
.onChange(of: jarvisAPIKey) { _, new in
|
||||||
|
settingsService.jarvisAPIKey = new.isEmpty ? nil : new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(showJarvisKey ? "Hide" : "Show") {
|
||||||
|
showJarvisKey.toggle()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: { Task { await testJarvisConnection() } }) {
|
||||||
|
HStack {
|
||||||
|
if isTestingJarvis {
|
||||||
|
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
}
|
||||||
|
Text("Test Connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isTestingJarvis || !settingsService.jarvisConfigured)
|
||||||
|
if let result = jarvisTestResult {
|
||||||
|
Text(result)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(result.hasPrefix("✓") ? .green : .red)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Generate an API key in your Jarvis settings and paste it above.")
|
||||||
|
}
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
jarvisURL = settingsService.jarvisURL
|
||||||
|
jarvisAPIKey = settingsService.jarvisAPIKey ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func testJarvisConnection() async {
|
||||||
|
isTestingJarvis = true
|
||||||
|
jarvisTestResult = nil
|
||||||
|
let ok = await JarvisService.shared.testConnection()
|
||||||
|
await MainActor.run {
|
||||||
|
jarvisTestResult = ok ? "✓ Connected" : "✗ Connection failed"
|
||||||
|
isTestingJarvis = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func testAnytypeConnection() async {
|
||||||
|
isTestingAnytype = true
|
||||||
|
anytypeTestResult = nil
|
||||||
|
let result = await AnytypeMCPService.shared.testConnection()
|
||||||
|
await MainActor.run {
|
||||||
|
switch result {
|
||||||
|
case .success(let msg):
|
||||||
|
anytypeTestResult = "✓ \(msg)"
|
||||||
|
case .failure(let err):
|
||||||
|
anytypeTestResult = "✗ \(err.localizedDescription)"
|
||||||
|
}
|
||||||
|
isTestingAnytype = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Backup Tab
|
// MARK: - Backup Tab
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -2112,9 +2376,9 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 68)
|
.frame(minWidth: 55)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 4)
|
||||||
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
@@ -2133,6 +2397,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
case 7: return "Skills"
|
case 7: return "Skills"
|
||||||
case 8: return "Paperless"
|
case 8: return "Paperless"
|
||||||
case 9: return "Backup"
|
case 9: return "Backup"
|
||||||
|
case 10: return "Anytype"
|
||||||
|
case 11: return "Jarvis"
|
||||||
default: return "Settings"
|
default: return "Settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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