Compare commits
18 Commits
098c3c3d1e
..
2.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1468c4f3c8 | |||
| 5b99a6f81c | |||
| a793fdacc4 | |||
| 414cf8cb8c | |||
| e7c7b9b5c6 | |||
| 87535dc2ad | |||
| 3dff8a8c8e | |||
| 00dccd648c | |||
| 92e393ab03 | |||
| 22f745762f | |||
| b3bb7c4a59 | |||
| ef1c05c13b | |||
| f63226b2cc | |||
| f3a0c45331 | |||
| 8451db1142 | |||
| cd0ceeab41 | |||
| 13699864d8 | |||
| c2010e272e |
@@ -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
|
||||||
|
|||||||
@@ -332,9 +332,6 @@ This means you are free to use, study, modify, and distribute oAI, but any modif
|
|||||||
|
|
||||||
See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html).
|
See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, database schema, and contribution guidelines.
|
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
@@ -344,9 +341,6 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, datab
|
|||||||
- Blog: [https://blog.rune.pm](https://blog.rune.pm)
|
- Blog: [https://blog.rune.pm](https://blog.rune.pm)
|
||||||
- Gitlab.pm: [@rune](https://gitlab.pm/rune)
|
- Gitlab.pm: [@rune](https://gitlab.pm/rune)
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instructions and project structure.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 26.2;
|
||||||
MARKETING_VERSION = 2.3.8;
|
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 = 26.2;
|
||||||
MARKETING_VERSION = 2.3.8;
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -500,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
|
||||||
@@ -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) {
|
|
||||||
// New conversation
|
|
||||||
Button(action: { chatViewModel.newConversation() }) {
|
|
||||||
ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
}
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
|
||||||
.help("New conversation")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showConversations = true }) {
|
private var isIntelNative: Bool {
|
||||||
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize)
|
var systemInfo = utsname()
|
||||||
|
uname(&systemInfo)
|
||||||
|
let machine = withUnsafeBytes(of: &systemInfo.machine) {
|
||||||
|
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
|
||||||
|
}
|
||||||
|
return machine.contains("x86_64")
|
||||||
}
|
}
|
||||||
.keyboardShortcut("l", modifiers: .command)
|
|
||||||
.help("Saved conversations (Cmd+L)")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showHistory = true }) {
|
private var isRosetta: Bool {
|
||||||
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize)
|
var ret: Int32 = 0
|
||||||
}
|
var size = MemoryLayout<Int32>.size
|
||||||
.keyboardShortcut("h", modifiers: .command)
|
sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
|
||||||
.help("Command history (Cmd+H)")
|
return ret == 1
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showModelSelector = true }) {
|
|
||||||
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("m", modifiers: .command)
|
|
||||||
.help("Select AI model (Cmd+M)")
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
if let model = chatViewModel.selectedModel {
|
|
||||||
chatViewModel.modelInfoTarget = model
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("i", modifiers: .command)
|
|
||||||
.help("Model info (Cmd+I)")
|
|
||||||
.disabled(chatViewModel.selectedModel == nil)
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showStats = true }) {
|
|
||||||
ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.help("Session statistics")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showCredits = true }) {
|
|
||||||
ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.help("Check API credits")
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showSettings = true }) {
|
|
||||||
ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut(",", modifiers: .command)
|
|
||||||
.help("Settings (Cmd+,)")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showHelp = true }) {
|
|
||||||
ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("/", modifiers: .command)
|
|
||||||
.help("Help & commands (Cmd+/)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper view for toolbar labels
|
|
||||||
struct ToolbarLabel: View {
|
|
||||||
let title: LocalizedStringKey
|
|
||||||
let systemImage: String
|
|
||||||
let showLabels: Bool
|
|
||||||
let iconSize: Double
|
|
||||||
|
|
||||||
// imageScale for the original range (≤32); explicit font size for the new extra-large range (>32)
|
|
||||||
private var scale: Image.Scale {
|
|
||||||
switch iconSize {
|
|
||||||
case ...18: return .small
|
|
||||||
case 19...24: return .medium
|
|
||||||
default: return .large
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if iconSize > 32 {
|
|
||||||
// Extra-large: explicit font size above the system .large ceiling
|
|
||||||
// Offset by 16 so slider 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-102
@@ -2,7 +2,8 @@
|
|||||||
// HeaderView.swift
|
// HeaderView.swift
|
||||||
// oAI
|
// oAI
|
||||||
//
|
//
|
||||||
// Header bar with provider, model, and stats
|
// Slim header — provider, model name, star only.
|
||||||
|
// Status pills and stats live in SidebarView and FooterView respectively.
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
@@ -28,19 +29,68 @@ import SwiftUI
|
|||||||
struct HeaderView: View {
|
struct HeaderView: View {
|
||||||
let provider: Settings.Provider
|
let provider: Settings.Provider
|
||||||
let model: ModelInfo?
|
let model: ModelInfo?
|
||||||
let stats: SessionStats
|
|
||||||
let onlineMode: Bool
|
|
||||||
let mcpEnabled: Bool
|
|
||||||
let mcpStatus: String?
|
|
||||||
let onModelSelect: () -> Void
|
let onModelSelect: () -> Void
|
||||||
let onProviderChange: (Settings.Provider) -> Void
|
let onProviderChange: (Settings.Provider) -> Void
|
||||||
|
var conversationName: String? = nil
|
||||||
|
var hasUnsavedChanges: Bool = false
|
||||||
|
var onQuickSave: (() -> Void)? = nil
|
||||||
private let settings = SettingsService.shared
|
private let settings = SettingsService.shared
|
||||||
private let registry = ProviderRegistry.shared
|
private let registry = ProviderRegistry.shared
|
||||||
private let gitSync = GitSyncService.shared
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 20) {
|
ZStack {
|
||||||
// Provider picker dropdown — only shows configured providers
|
// Left: provider + model + star
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
providerMenu
|
||||||
|
modelButton
|
||||||
|
starButton
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center: conversation title (macOS document-title style)
|
||||||
|
conversationTitle
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.oaiBorder.opacity(0.5))
|
||||||
|
.frame(height: 1),
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conversation title (center)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var conversationTitle: some View {
|
||||||
|
if let name = conversationName {
|
||||||
|
Button(action: { if hasUnsavedChanges { onQuickSave?() } }) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
if hasUnsavedChanges {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.orange)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
}
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
|
||||||
|
.foregroundColor(.oaiPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!hasUnsavedChanges)
|
||||||
|
.help(hasUnsavedChanges ? "Unsaved changes — click to save" : "Saved")
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subviews (extracted so ZStack stays readable)
|
||||||
|
|
||||||
|
private var providerMenu: some View {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(registry.configuredProviders, id: \.self) { p in
|
ForEach(registry.configuredProviders, id: \.self) { p in
|
||||||
Button {
|
Button {
|
||||||
@@ -49,9 +99,7 @@ struct HeaderView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Image(systemName: p.iconName)
|
Image(systemName: p.iconName)
|
||||||
Text(p.displayName)
|
Text(p.displayName)
|
||||||
if p == provider {
|
if p == provider { Image(systemName: "checkmark") }
|
||||||
Image(systemName: "checkmark")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,58 +122,46 @@ struct HeaderView: View {
|
|||||||
.menuStyle(.borderlessButton)
|
.menuStyle(.borderlessButton)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
.help("Switch provider")
|
.help("Switch provider")
|
||||||
|
}
|
||||||
|
|
||||||
// Model info (clickable → model selector)
|
private var modelButton: some View {
|
||||||
Button(action: onModelSelect) {
|
Button(action: onModelSelect) {
|
||||||
if let model = model {
|
if let model = model {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(model.name)
|
Text(model.name)
|
||||||
.font(.system(size: settings.guiTextSize, weight: .medium))
|
.font(.system(size: settings.guiTextSize, weight: .medium))
|
||||||
.foregroundColor(.oaiPrimary)
|
.foregroundColor(.oaiPrimary)
|
||||||
|
|
||||||
// Capability badges
|
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
if model.capabilities.vision {
|
if model.capabilities.vision {
|
||||||
Image(systemName: "eye")
|
Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
}
|
||||||
if model.capabilities.tools {
|
if model.capabilities.tools {
|
||||||
Image(systemName: "wrench")
|
Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
}
|
||||||
if model.capabilities.online {
|
if model.capabilities.online {
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
}
|
||||||
if model.capabilities.imageGeneration {
|
if model.capabilities.imageGeneration {
|
||||||
Image(systemName: "paintbrush")
|
Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text("No model selected")
|
Text("No model selected")
|
||||||
.font(.system(size: settings.guiTextSize))
|
.font(.system(size: settings.guiTextSize))
|
||||||
.foregroundColor(.oaiSecondary)
|
.foregroundColor(.oaiSecondary)
|
||||||
|
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Select model")
|
.help("Select model")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var starButton: some View {
|
||||||
if let model = model {
|
if let model = model {
|
||||||
let isFav = settings.favoriteModelIds.contains(model.id)
|
let isFav = settings.favoriteModelIds.contains(model.id)
|
||||||
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
||||||
@@ -136,68 +172,10 @@ struct HeaderView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Status indicators
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if model?.capabilities.imageGeneration == true {
|
|
||||||
StatusPill(icon: "paintbrush", label: "Image", color: .purple)
|
|
||||||
}
|
|
||||||
if onlineMode {
|
|
||||||
StatusPill(icon: "globe", label: "Online", color: .green)
|
|
||||||
}
|
|
||||||
if mcpEnabled {
|
|
||||||
StatusPill(icon: "folder", label: "MCP", color: .blue)
|
|
||||||
}
|
|
||||||
if settings.syncEnabled && settings.syncAutoSave {
|
|
||||||
SyncStatusPill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Divider between status and stats
|
|
||||||
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) {
|
|
||||||
Divider()
|
|
||||||
.frame(height: 16)
|
|
||||||
.opacity(0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick stats
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
StatItem(icon: "message", value: "\(stats.messageCount)")
|
|
||||||
StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay)
|
|
||||||
StatItem(icon: "dollarsign", value: stats.totalCostDisplay)
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(.ultraThinMaterial)
|
|
||||||
.overlay(
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.oaiBorder.opacity(0.5))
|
|
||||||
.frame(height: 1),
|
|
||||||
alignment: .bottom
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatItem: View {
|
// MARK: - Status Pills (used by SidebarView)
|
||||||
let icon: String
|
|
||||||
let value: String
|
|
||||||
private let settings = SettingsService.shared
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: settings.guiTextSize - 3))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
Text(value)
|
|
||||||
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
|
|
||||||
.foregroundColor(.oaiPrimary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StatusPill: View {
|
struct StatusPill: View {
|
||||||
let icon: String
|
let icon: String
|
||||||
@@ -284,15 +262,6 @@ struct SyncStatusPill: View {
|
|||||||
HeaderView(
|
HeaderView(
|
||||||
provider: .openrouter,
|
provider: .openrouter,
|
||||||
model: ModelInfo.mockModels.first,
|
model: ModelInfo.mockModels.first,
|
||||||
stats: SessionStats(
|
|
||||||
totalInputTokens: 125,
|
|
||||||
totalOutputTokens: 434,
|
|
||||||
totalCost: 0.00111,
|
|
||||||
messageCount: 4
|
|
||||||
),
|
|
||||||
onlineMode: true,
|
|
||||||
mcpEnabled: true,
|
|
||||||
mcpStatus: "MCP",
|
|
||||||
onModelSelect: {},
|
onModelSelect: {},
|
||||||
onProviderChange: { _ in }
|
onProviderChange: { _ in }
|
||||||
)
|
)
|
||||||
|
|||||||
+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
|
||||||
|
dragHandle
|
||||||
|
|
||||||
|
// Input row
|
||||||
HStack(alignment: .bottom, spacing: 12) {
|
HStack(alignment: .bottom, spacing: 12) {
|
||||||
// Status indicators
|
// Text input with globe toggle in bottom-left corner
|
||||||
HStack(spacing: 6) {
|
|
||||||
if let mcp = mcpStatus {
|
|
||||||
StatusBadge(text: mcp, color: .blue)
|
|
||||||
}
|
|
||||||
if onlineMode {
|
|
||||||
StatusBadge(text: "🌐", color: .green)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 80, alignment: .leading)
|
|
||||||
|
|
||||||
// Text input
|
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
|
// Placeholder
|
||||||
if text.isEmpty {
|
if text.isEmpty {
|
||||||
Text("Type a message or / for commands...")
|
Text("Type a message or / for commands...")
|
||||||
.font(.system(size: settings.inputTextSize))
|
.font(.system(size: settings.inputTextSize))
|
||||||
.foregroundColor(.oaiSecondary)
|
.foregroundColor(.oaiSecondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.top, 10)
|
||||||
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
TextEditor(text: $text)
|
// Editor — fills the fixed-height box, bottom area reserved for globe
|
||||||
.font(.system(size: settings.inputTextSize))
|
NativeTextEditor(
|
||||||
.foregroundColor(.oaiPrimary)
|
text: $text,
|
||||||
.scrollContentBackground(.hidden)
|
font: .systemFont(ofSize: settings.inputTextSize),
|
||||||
.frame(minHeight: 44, maxHeight: 120)
|
textColor: NSColor(Color.oaiPrimary),
|
||||||
.padding(.horizontal, 8)
|
isFocused: isInputFocused,
|
||||||
.padding(.vertical, 6)
|
onReturn: {
|
||||||
.focused($isInputFocused)
|
|
||||||
.onChange(of: text) {
|
|
||||||
showCommandDropdown = text.hasPrefix("/")
|
|
||||||
selectedSuggestionIndex = 0
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
|
||||||
.onKeyPress(.upArrow) {
|
|
||||||
// Navigate command dropdown
|
|
||||||
if showCommandDropdown && selectedSuggestionIndex > 0 {
|
|
||||||
selectedSuggestionIndex -= 1
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
.onKeyPress(.downArrow) {
|
|
||||||
// Navigate command dropdown
|
|
||||||
if showCommandDropdown {
|
|
||||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
|
||||||
if selectedSuggestionIndex < count - 1 {
|
|
||||||
selectedSuggestionIndex += 1
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
.onKeyPress(.escape) {
|
|
||||||
// If command dropdown is showing, close it
|
|
||||||
if showCommandDropdown {
|
|
||||||
showCommandDropdown = false
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
// If model is generating, cancel it
|
|
||||||
if isGenerating {
|
|
||||||
onCancel()
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
.onKeyPress(.return, phases: .down) { press in
|
|
||||||
// Shift+Return: always insert newline (let system handle)
|
|
||||||
if press.modifiers.contains(.shift) {
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
// If command dropdown is showing, select the highlighted command
|
|
||||||
if showCommandDropdown {
|
if showCommandDropdown {
|
||||||
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
|
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
|
||||||
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
|
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
|
||||||
selectCommand(suggestions[selectedSuggestionIndex].command)
|
selectCommand(suggestions[selectedSuggestionIndex].command)
|
||||||
return .handled
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Return (plain or with Cmd): send message
|
if !text.isEmpty { onSend(); return true }
|
||||||
if !text.isEmpty {
|
return true
|
||||||
onSend()
|
},
|
||||||
return .handled
|
onEscape: {
|
||||||
|
if showCommandDropdown { showCommandDropdown = false; return true }
|
||||||
|
if isGenerating { onCancel(); return true }
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onUpArrow: {
|
||||||
|
if showCommandDropdown && selectedSuggestionIndex > 0 {
|
||||||
|
selectedSuggestionIndex -= 1; return true
|
||||||
}
|
}
|
||||||
// Empty text: do nothing
|
return false
|
||||||
return .handled
|
},
|
||||||
|
onDownArrow: {
|
||||||
|
if showCommandDropdown {
|
||||||
|
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||||
|
if selectedSuggestionIndex < count - 1 {
|
||||||
|
selectedSuggestionIndex += 1; return true
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onFocusChange: { focused in isInputFocused = focused }
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onChange(of: text) {
|
||||||
|
showCommandDropdown = text.hasPrefix("/")
|
||||||
|
selectedSuggestionIndex = 0
|
||||||
|
}
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
|
// Online / offline toggle — bottom-left of the text box
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Button(action: onToggleOnline) {
|
||||||
|
Image(systemName: onlineMode ? "globe" : "network.slash")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(onlineMode ? Color.green : Color.secondary)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(onlineMode
|
||||||
|
? "Online mode on — click to go offline"
|
||||||
|
: "Offline — click to go online")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: inputHeight)
|
||||||
.background(Color.oaiSurface)
|
.background(Color.oaiSurface)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.overlay(
|
.overlay(
|
||||||
@@ -167,10 +166,9 @@ struct InputBar: View {
|
|||||||
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
|
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Action buttons
|
// Send / stop + attach buttons
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// File attach button
|
|
||||||
Button(action: pickFile) {
|
Button(action: pickFile) {
|
||||||
Image(systemName: "paperclip")
|
Image(systemName: "paperclip")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
@@ -209,21 +207,47 @@ struct InputBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Drag handle
|
||||||
|
|
||||||
|
private var dragHandle: some View {
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 8)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.overlay {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.secondary.opacity(0.25))
|
||||||
|
.frame(width: 36, height: 3)
|
||||||
|
}
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 1)
|
||||||
|
.onChanged { value in
|
||||||
|
let proposed = dragStartHeight - value.translation.height
|
||||||
|
inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed))
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
dragStartHeight = inputHeight
|
||||||
|
settings.inputBarHeight = Double(inputHeight)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#if os(macOS)
|
||||||
|
.onHover { hovering in
|
||||||
|
if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func selectCommand(_ command: String) {
|
private func selectCommand(_ command: String) {
|
||||||
showCommandDropdown = false
|
showCommandDropdown = false
|
||||||
if Self.immediateCommands.contains(command) {
|
if Self.immediateCommands.contains(command) {
|
||||||
// Execute immediately
|
|
||||||
text = command
|
text = command
|
||||||
onSend()
|
onSend()
|
||||||
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
|
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
|
||||||
if shortcut.needsInput {
|
text = shortcut.needsInput ? command + " " : command
|
||||||
text = command + " "
|
if !shortcut.needsInput { onSend() }
|
||||||
} else {
|
} else {
|
||||||
text = command
|
|
||||||
onSend()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Put in input for user to complete
|
|
||||||
text = command + " "
|
text = command + " "
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,36 +259,14 @@ struct InputBar: View {
|
|||||||
panel.canChooseDirectories = false
|
panel.canChooseDirectories = false
|
||||||
panel.canChooseFiles = true
|
panel.canChooseFiles = true
|
||||||
panel.message = "Select files to attach"
|
panel.message = "Select files to attach"
|
||||||
|
|
||||||
guard panel.runModal() == .OK else { return }
|
guard panel.runModal() == .OK else { return }
|
||||||
|
let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ")
|
||||||
let paths = panel.urls.map { $0.path }
|
text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText
|
||||||
// Use @<path> format (angle brackets) to safely handle paths with spaces
|
|
||||||
let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ")
|
|
||||||
|
|
||||||
if text.isEmpty {
|
|
||||||
text = attachmentText + " "
|
|
||||||
} else {
|
|
||||||
text += " " + attachmentText
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatusBadge: View {
|
// MARK: - Command suggestions
|
||||||
let text: String
|
|
||||||
let color: Color
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(text)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(color)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(color.opacity(0.15))
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CommandSuggestionsView: View {
|
struct CommandSuggestionsView: View {
|
||||||
let searchText: String
|
let searchText: String
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ struct ModelInfoView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Bindable private var settings = SettingsService.shared
|
@Bindable private var settings = SettingsService.shared
|
||||||
|
@State private var isDescriptionExpanded = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -78,8 +79,18 @@ struct ModelInfoView: View {
|
|||||||
Text(desc)
|
Text(desc)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.lineLimit(isDescriptionExpanded ? nil : 4)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
if desc.count > 250 {
|
||||||
|
Button(isDescriptionExpanded ? "Less" : "More…") {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
isDescriptionExpanded.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
@@ -145,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()
|
||||||
@@ -238,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",
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ struct ModelSelectorView: View {
|
|||||||
@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 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
|
@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()
|
||||||
let filtered = models.filter { model in
|
let filtered = models.filter { model in
|
||||||
@@ -57,8 +63,9 @@ struct ModelSelectorView: View {
|
|||||||
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 matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
|
||||||
|
let matchesCategory = selectedCategory == nil || model.categories.contains(selectedCategory!)
|
||||||
|
|
||||||
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites
|
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites && matchesCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
let favIds = settings.favoriteModelIds
|
let favIds = settings.favoriteModelIds
|
||||||
@@ -100,6 +107,40 @@ 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
|
// Favorites filter star
|
||||||
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
||||||
Image(systemName: filterFavorites ? "star.fill" : "star")
|
Image(systemName: filterFavorites ? "star.fill" : "star")
|
||||||
@@ -180,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) {
|
||||||
@@ -255,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)
|
||||||
@@ -264,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,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) {
|
||||||
@@ -335,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")
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ struct SettingsView: View {
|
|||||||
@State private var isTestingAnytype = false
|
@State private var isTestingAnytype = false
|
||||||
@State private var anytypeTestResult: String?
|
@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
|
// Default model picker state
|
||||||
@State private var showDefaultModelPicker = false
|
@State private var showDefaultModelPicker = false
|
||||||
|
|
||||||
@@ -155,6 +162,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
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(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)
|
||||||
@@ -186,6 +194,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
backupTab
|
backupTab
|
||||||
case 10:
|
case 10:
|
||||||
anytypeTab
|
anytypeTab
|
||||||
|
case 11:
|
||||||
|
jarvisTab
|
||||||
default:
|
default:
|
||||||
generalTab
|
generalTab
|
||||||
}
|
}
|
||||||
@@ -2047,6 +2057,107 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
private func testAnytypeConnection() async {
|
||||||
isTestingAnytype = true
|
isTestingAnytype = true
|
||||||
anytypeTestResult = nil
|
anytypeTestResult = nil
|
||||||
@@ -2265,7 +2376,7 @@ 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: 60)
|
.frame(minWidth: 55)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
@@ -2286,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