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

|
||||
|
||||
@@ -97,8 +107,8 @@ Automated email responses powered by AI:
|
||||
|
||||
Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-swift/releases). Two builds are available:
|
||||
|
||||
- **oAI-x.x.x-AppleSilicon.dmg** — for Macs with an Apple Silicon chip (M1 and later)
|
||||
- **oAI-x.x.x-Universal.dmg** — runs natively on both Apple Silicon and Intel Macs
|
||||
- **oAI-x.x.x-AppleSilicon.dmg** - for Macs with an Apple Silicon chip (M1 and later)
|
||||
- **oAI-x.x.x-Universal.dmg** - runs natively on both Apple Silicon and Intel Macs
|
||||
|
||||
### Installing from DMG
|
||||
|
||||
@@ -107,19 +117,19 @@ Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-
|
||||
3. Eject the DMG
|
||||
4. Launch oAI from Applications or Spotlight
|
||||
|
||||
### First Launch — Gatekeeper Warning
|
||||
### First Launch - Gatekeeper Warning
|
||||
|
||||
oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan — the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified."
|
||||
oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan - the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified."
|
||||
|
||||
To open the app, you have two options:
|
||||
|
||||
**Option A — Right-click to open (quickest):**
|
||||
**Option A - Right-click to open (quickest):**
|
||||
1. Right-click (or Control-click) `oAI.app` in Applications
|
||||
2. Select **Open** from the context menu
|
||||
3. Click **Open** in the dialog that appears
|
||||
4. After doing this once, the app opens normally from then on
|
||||
|
||||
**Option B — Remove the quarantine flag via Terminal:**
|
||||
**Option B - Remove the quarantine flag via Terminal:**
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/oAI.app
|
||||
@@ -139,7 +149,7 @@ Add your API keys in Settings (⌘,) → General tab:
|
||||
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth
|
||||
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
|
||||
- **Ollama** - Base URL (default: http://localhost:11434)
|
||||
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) — not a chat provider
|
||||
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) - not a chat provider
|
||||
|
||||
### Essential Settings
|
||||
|
||||
@@ -313,12 +323,16 @@ AI-powered email auto-responder:
|
||||
|
||||
- [x] Vector index for faster semantic search (sqlite-vss)
|
||||
- [x] Reasoning / thinking tokens (streamed live, collapsible)
|
||||
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German)
|
||||
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German, French)
|
||||
- [x] iCloud Backup (settings export/restore)
|
||||
- [x] Bash execution with per-command approval
|
||||
- [x] Anytype integration (read, append, create, checkbox tools)
|
||||
- [x] Model favourites (starred models, filter, float to top)
|
||||
- [ ] SOUL.md / USER.md — living identity documents injected into system prompt
|
||||
- [x] Jarvis integration (agent management, run history, usage/credits)
|
||||
- [x] Model category filter (Programming, Math, Medical, etc.)
|
||||
- [x] Combine saved conversations (concatenation or AI-assisted synthesis)
|
||||
- [x] Sidebar navigation redesign
|
||||
- [ ] SOUL.md / USER.md - living identity documents injected into system prompt
|
||||
- [ ] Parallel research agents (read-only, concurrent)
|
||||
- [ ] Local embeddings (sentence-transformers, $0 cost)
|
||||
- [ ] Multi-modal conversation export (PDF, HTML)
|
||||
@@ -332,9 +346,6 @@ This means you are free to use, study, modify, and distribute oAI, but any modif
|
||||
|
||||
See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
||||
## Development
|
||||
|
||||
See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, database schema, and contribution guidelines.
|
||||
|
||||
## Author
|
||||
|
||||
@@ -344,9 +355,12 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, datab
|
||||
- Blog: [https://blog.rune.pm](https://blog.rune.pm)
|
||||
- Gitlab.pm: [@rune](https://gitlab.pm/rune)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instructions and project structure.
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
oAI takes real actions on your behalf - it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind - the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
da,
|
||||
de,
|
||||
sv,
|
||||
fr,
|
||||
);
|
||||
mainGroup = A550A6592F3B72EA00136F2B;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
@@ -269,6 +270,11 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
|
||||
INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings.";
|
||||
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings.";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -279,11 +285,11 @@
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown 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[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.3.8;
|
||||
MARKETING_VERSION = 2.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -313,6 +319,11 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
|
||||
INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings.";
|
||||
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings.";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -323,11 +334,11 @@
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown 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[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.3.8;
|
||||
MARKETING_VERSION = 2.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,14 @@
|
||||
"filename" : "AppLogo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -6,5 +6,13 @@
|
||||
<string>oAI.help</string>
|
||||
<key>CFBundleHelpBookName</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,4 +57,10 @@ struct AgentSkill: Codable, Identifiable {
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/// Matches the user's "2nd Brain" skill by name — there's no canonical skill ID,
|
||||
/// so this is the only way to recognize it (used to gate the "always trust" bash setting).
|
||||
var isSecondBrainSkill: Bool {
|
||||
name.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("2nd Brain") == .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ struct Conversation: Identifiable, Codable {
|
||||
var updatedAt: Date
|
||||
var primaryModel: String? // Primary model used in this conversation
|
||||
|
||||
init(
|
||||
nonisolated init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
messages: [Message] = [],
|
||||
|
||||
@@ -44,7 +44,7 @@ struct EmailLog: Identifiable, Codable, Equatable {
|
||||
let responseTime: TimeInterval? // Time to generate response in seconds
|
||||
let modelId: String? // Model that handled the email
|
||||
|
||||
init(
|
||||
nonisolated init(
|
||||
id: UUID = UUID(),
|
||||
timestamp: Date = Date(),
|
||||
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)
|
||||
var thinkingContent: String? = nil
|
||||
|
||||
init(
|
||||
nonisolated init(
|
||||
id: UUID = UUID(),
|
||||
role: MessageRole,
|
||||
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
|
||||
var architecture: Architecture? = nil
|
||||
var topProvider: String? = nil
|
||||
var categories: [ModelCategory] = []
|
||||
|
||||
struct Pricing: Codable, Hashable {
|
||||
let prompt: Double // per 1M tokens
|
||||
|
||||
@@ -130,11 +130,23 @@ struct ChatResponse: Codable {
|
||||
let promptTokens: Int
|
||||
let completionTokens: 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 {
|
||||
case promptTokens = "prompt_tokens"
|
||||
case completionTokens = "completion_tokens"
|
||||
case totalTokens = "total_tokens"
|
||||
case cacheCreationInputTokens = "cache_creation_input_tokens"
|
||||
case cacheReadInputTokens = "cache_read_input_tokens"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +229,19 @@ struct Tool: Codable {
|
||||
let type: String
|
||||
let description: String
|
||||
let `enum`: [String]?
|
||||
let items: Items?
|
||||
|
||||
/// Item schema for `type: "array"` properties (e.g. an array of strings).
|
||||
struct Items: Codable {
|
||||
let type: String
|
||||
}
|
||||
|
||||
init(type: String, description: String, enum: [String]? = nil, items: Items? = nil) {
|
||||
self.type = type
|
||||
self.description = description
|
||||
self.enum = `enum`
|
||||
self.items = items
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,15 @@ class AnthropicProvider: AIProvider {
|
||||
/// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301")
|
||||
/// still inherit the correct pricing tier.
|
||||
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
|
||||
ModelInfo(
|
||||
id: "claude-opus-4-6",
|
||||
@@ -173,6 +182,7 @@ class AnthropicProvider: AIProvider {
|
||||
/// Pricing tiers used for fuzzy fallback matching on unknown model IDs.
|
||||
/// Keyed by model name prefix (longest match wins).
|
||||
private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [
|
||||
("claude-fable", 10.0, 50.0),
|
||||
("claude-opus", 15.0, 75.0),
|
||||
("claude-sonnet", 3.0, 15.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] = [
|
||||
"model": model,
|
||||
"messages": conversationMessages,
|
||||
@@ -363,7 +386,9 @@ class AnthropicProvider: AIProvider {
|
||||
"stream": false
|
||||
]
|
||||
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 {
|
||||
body["temperature"] = temperature
|
||||
@@ -430,6 +455,8 @@ class AnthropicProvider: AIProvider {
|
||||
var currentId = ""
|
||||
var currentModel = request.model
|
||||
var inputTokens = 0
|
||||
var cacheCreationTokens: Int? = nil
|
||||
var cacheReadTokens: Int? = nil
|
||||
|
||||
for try await line in bytes.lines {
|
||||
// Anthropic SSE: "event: ..." and "data: {...}"
|
||||
@@ -449,6 +476,11 @@ class AnthropicProvider: AIProvider {
|
||||
currentModel = message["model"] as? String ?? request.model
|
||||
if let usageDict = message["usage"] as? [String: Any] {
|
||||
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
|
||||
if let usageDict = event["usage"] as? [String: Any] {
|
||||
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(
|
||||
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] = [
|
||||
"model": request.model,
|
||||
"messages": apiMessages,
|
||||
@@ -590,7 +641,10 @@ class AnthropicProvider: AIProvider {
|
||||
]
|
||||
|
||||
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 {
|
||||
body["temperature"] = temperature
|
||||
@@ -665,6 +719,11 @@ class AnthropicProvider: AIProvider {
|
||||
let usageDict = json["usage"] as? [String: Any]
|
||||
let inputTokens = usageDict?["input_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(
|
||||
id: id,
|
||||
@@ -675,7 +734,9 @@ class AnthropicProvider: AIProvider {
|
||||
usage: ChatResponse.Usage(
|
||||
promptTokens: inputTokens,
|
||||
completionTokens: outputTokens,
|
||||
totalTokens: inputTokens + outputTokens
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
cacheCreationInputTokens: cacheCreationTokens,
|
||||
cacheReadInputTokens: cacheReadTokens
|
||||
),
|
||||
created: Date(),
|
||||
toolCalls: toolCalls.isEmpty ? nil : toolCalls
|
||||
@@ -692,6 +753,9 @@ class AnthropicProvider: AIProvider {
|
||||
if let enumVals = prop.enum {
|
||||
propDict["enum"] = enumVals
|
||||
}
|
||||
if let items = prop.items {
|
||||
propDict["items"] = ["type": items.type]
|
||||
}
|
||||
props[key] = propDict
|
||||
}
|
||||
var dict: [String: Any] = [
|
||||
|
||||
@@ -48,7 +48,12 @@ struct OpenRouterChatRequest: Codable {
|
||||
let toolChoice: String?
|
||||
let modalities: [String]?
|
||||
let reasoning: ReasoningAPIConfig?
|
||||
|
||||
let cacheControl: CacheControl?
|
||||
|
||||
struct CacheControl: Codable {
|
||||
let type: String
|
||||
}
|
||||
|
||||
struct APIMessage: Codable {
|
||||
let role: String
|
||||
let content: MessageContent
|
||||
@@ -138,6 +143,7 @@ struct OpenRouterChatRequest: Codable {
|
||||
case toolChoice = "tool_choice"
|
||||
case modalities
|
||||
case reasoning
|
||||
case cacheControl = "cache_control"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,11 +231,23 @@ struct OpenRouterChatResponse: Codable {
|
||||
let promptTokens: Int
|
||||
let completionTokens: 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 {
|
||||
case promptTokens = "prompt_tokens"
|
||||
case completionTokens = "completion_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 completionPrice = Double(modelData.pricing.completion) ?? 0.0
|
||||
|
||||
return ModelInfo(
|
||||
var info = ModelInfo(
|
||||
id: modelData.id,
|
||||
name: modelData.name,
|
||||
description: modelData.description,
|
||||
@@ -122,6 +122,12 @@ class OpenRouterProvider: AIProvider {
|
||||
},
|
||||
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 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)
|
||||
urlRequest.httpMethod = "POST"
|
||||
@@ -382,6 +393,12 @@ class OpenRouterProvider: AIProvider {
|
||||
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(
|
||||
model: effectiveModel,
|
||||
messages: apiMessages,
|
||||
@@ -392,7 +409,8 @@ class OpenRouterProvider: AIProvider {
|
||||
tools: request.tools,
|
||||
toolChoice: request.tools != nil ? "auto" : 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 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(
|
||||
id: apiResponse.id,
|
||||
model: apiResponse.model,
|
||||
@@ -420,7 +443,9 @@ class OpenRouterProvider: AIProvider {
|
||||
ChatResponse.Usage(
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
totalTokens: usage.totalTokens
|
||||
totalTokens: usage.totalTokens,
|
||||
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
|
||||
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
|
||||
)
|
||||
},
|
||||
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
|
||||
@@ -440,6 +465,11 @@ class OpenRouterProvider: AIProvider {
|
||||
let allImages = topLevelImages + blockImages
|
||||
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(
|
||||
id: apiChunk.id,
|
||||
model: apiChunk.model,
|
||||
@@ -454,7 +484,9 @@ class OpenRouterProvider: AIProvider {
|
||||
ChatResponse.Usage(
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
totalTokens: usage.totalTokens
|
||||
totalTokens: usage.totalTokens,
|
||||
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
|
||||
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//
|
||||
// ContactsService.swift
|
||||
// oAI
|
||||
//
|
||||
// Read-only Contacts integration: search and "my card" lookup
|
||||
//
|
||||
// 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 Contacts
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
class ContactsService {
|
||||
static let shared = ContactsService()
|
||||
|
||||
private let store = CNContactStore()
|
||||
private let maxResults = 20
|
||||
private let maxScan = 5000
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
var authStatus: CNAuthorizationStatus {
|
||||
CNContactStore.authorizationStatus(for: .contacts)
|
||||
}
|
||||
|
||||
var authorized: Bool {
|
||||
authStatus == .authorized
|
||||
}
|
||||
|
||||
var accessState: PersonalDataAccessState {
|
||||
let status = authStatus
|
||||
Log.mcp.debug("ContactsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
switch status {
|
||||
case .authorized: return .granted
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestAccess() async -> Bool {
|
||||
let before = CNContactStore.authorizationStatus(for: .contacts)
|
||||
Log.mcp.info("ContactsService.requestAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||
return await withCheckedContinuation { continuation in
|
||||
store.requestAccess(for: .contacts) { granted, error in
|
||||
let after = CNContactStore.authorizationStatus(for: .contacts)
|
||||
if let error {
|
||||
Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
} else {
|
||||
Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
}
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func describe(_ status: CNAuthorizationStatus) -> String {
|
||||
switch status {
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
case .denied: return "denied"
|
||||
case .authorized: return "authorized"
|
||||
case .limited: return "limited"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool Schemas
|
||||
|
||||
func getToolSchemas() -> [Tool] {
|
||||
[
|
||||
makeTool(
|
||||
name: "contacts_search",
|
||||
description: "Search Contacts by name, phone number, or email address. Returns matching contacts with their phone numbers and emails. This does NOT match relationship labels like 'mother' or 'spouse' — for those, call contacts_get_me first to find the related person's name, then search for that name.",
|
||||
properties: [
|
||||
"query": prop("string", "Name, phone number, or email fragment to search for")
|
||||
],
|
||||
required: ["query"]
|
||||
),
|
||||
makeTool(
|
||||
name: "contacts_get_me",
|
||||
description: "Get the user's own contact card (\"My Card\" in Contacts.app), if configured. Includes any defined relationships (e.g. mother, spouse, child) with the related person's name — use contacts_search with that name to find their phone/email.",
|
||||
properties: [:],
|
||||
required: []
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||
Log.mcp.info("Executing Contacts tool: \(name)")
|
||||
guard authorized else {
|
||||
return ["error": "Contacts permission not granted. Grant access in Settings > MCP."]
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "contacts_search":
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let args = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let query = args["query"] as? String, !query.isEmpty else {
|
||||
return ["error": "Missing required parameter: query"]
|
||||
}
|
||||
return search(query: query)
|
||||
|
||||
case "contacts_get_me":
|
||||
return getMe()
|
||||
|
||||
default:
|
||||
return ["error": "Unknown Contacts tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Implementation
|
||||
|
||||
private let keysToFetch: [CNKeyDescriptor] = [
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||
CNContactRelationsKey as CNKeyDescriptor
|
||||
]
|
||||
|
||||
private func search(query: String) -> [String: Any] {
|
||||
var matches: [CNContact] = []
|
||||
|
||||
// Fast path: name predicate
|
||||
let namePredicate = CNContact.predicateForContacts(matchingName: query)
|
||||
if let nameMatches = try? store.unifiedContacts(matching: namePredicate, keysToFetch: keysToFetch) {
|
||||
matches.append(contentsOf: nameMatches)
|
||||
}
|
||||
|
||||
// Fallback: scan for phone/email substring matches
|
||||
if matches.isEmpty {
|
||||
let lowerQuery = query.lowercased()
|
||||
let digitsQuery = query.filter(\.isNumber)
|
||||
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
|
||||
var scanned = 0
|
||||
try? store.enumerateContacts(with: request) { contact, stop in
|
||||
scanned += 1
|
||||
if scanned > self.maxScan || matches.count >= self.maxResults {
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
let emailMatch = contact.emailAddresses.contains {
|
||||
($0.value as String).lowercased().contains(lowerQuery)
|
||||
}
|
||||
let phoneMatch = !digitsQuery.isEmpty && contact.phoneNumbers.contains {
|
||||
$0.value.stringValue.filter(\.isNumber).contains(digitsQuery)
|
||||
}
|
||||
if emailMatch || phoneMatch {
|
||||
matches.append(contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let deduped = dedupContacts(matches)
|
||||
let formatted = deduped.prefix(maxResults).map(formatContact)
|
||||
return ["count": formatted.count, "contacts": Array(formatted)]
|
||||
}
|
||||
|
||||
/// Collapses contacts that share a phone number or email — Contacts.app's "linked contacts"
|
||||
/// merge doesn't catch every real-world duplicate card, so do a best-effort merge here too.
|
||||
private func dedupContacts(_ contacts: [CNContact]) -> [CNContact] {
|
||||
var result: [CNContact] = []
|
||||
outer: for contact in contacts {
|
||||
let phones = Set(contact.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) })
|
||||
let emails = Set(contact.emailAddresses.map { ($0.value as String).lowercased() })
|
||||
for existing in result {
|
||||
let existingPhones = Set(existing.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) })
|
||||
let existingEmails = Set(existing.emailAddresses.map { ($0.value as String).lowercased() })
|
||||
if !phones.isDisjoint(with: existingPhones) || !emails.isDisjoint(with: existingEmails) {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
result.append(contact)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func getMe() -> [String: Any] {
|
||||
guard let me = try? store.unifiedMeContactWithKeys(toFetch: keysToFetch) else {
|
||||
return ["error": "No 'My Card' is configured in Contacts.app"]
|
||||
}
|
||||
return formatContact(me)
|
||||
}
|
||||
|
||||
private func formatContact(_ contact: CNContact) -> [String: Any] {
|
||||
var item: [String: Any] = [
|
||||
"given_name": contact.givenName,
|
||||
"family_name": contact.familyName
|
||||
]
|
||||
if !contact.organizationName.isEmpty {
|
||||
item["organization"] = contact.organizationName
|
||||
}
|
||||
if !contact.phoneNumbers.isEmpty {
|
||||
item["phones"] = contact.phoneNumbers.map { $0.value.stringValue }
|
||||
}
|
||||
if !contact.emailAddresses.isEmpty {
|
||||
item["emails"] = contact.emailAddresses.map { $0.value as String }
|
||||
}
|
||||
if !contact.contactRelations.isEmpty {
|
||||
item["relations"] = contact.contactRelations.map { labeled -> [String: String] in
|
||||
let label = labeled.label.map { CNLabeledValue<CNContactRelation>.localizedString(forLabel: $0) } ?? "relation"
|
||||
return ["label": label, "name": labeled.value.name]
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||
Tool(
|
||||
type: "function",
|
||||
function: Tool.Function(
|
||||
name: name,
|
||||
description: description,
|
||||
parameters: Tool.Function.Parameters(
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
|
||||
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
private let dbQueue: DatabaseQueue
|
||||
private let isoFormatter: ISO8601DateFormatter
|
||||
|
||||
// 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() {
|
||||
isoFormatter = ISO8601DateFormatter()
|
||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
|
||||
@@ -156,7 +170,7 @@ final class DatabaseService: Sendable {
|
||||
try! migrator.migrate(dbQueue)
|
||||
}
|
||||
|
||||
private var migrator: DatabaseMigrator {
|
||||
private nonisolated var migrator: DatabaseMigrator {
|
||||
var migrator = DatabaseMigrator()
|
||||
|
||||
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 {
|
||||
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
|
||||
let now = Date()
|
||||
let nowString = isoFormatter.string(from: now)
|
||||
let nowString = Self.isoString(from: now)
|
||||
|
||||
let convRecord = ConversationRecord(
|
||||
id: id.uuidString,
|
||||
@@ -394,7 +408,7 @@ final class DatabaseService: Sendable {
|
||||
content: msg.content,
|
||||
tokens: msg.tokens,
|
||||
cost: msg.cost,
|
||||
timestamp: isoFormatter.string(from: msg.timestamp),
|
||||
timestamp: Self.isoString(from: msg.timestamp),
|
||||
sortOrder: index,
|
||||
modelId: msg.modelId
|
||||
)
|
||||
@@ -420,7 +434,7 @@ final class DatabaseService: Sendable {
|
||||
|
||||
/// Update an existing conversation in-place: rename it, replace all its messages.
|
||||
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
|
||||
guard msg.role != .system else { return nil }
|
||||
@@ -431,7 +445,7 @@ final class DatabaseService: Sendable {
|
||||
content: msg.content,
|
||||
tokens: msg.tokens,
|
||||
cost: msg.cost,
|
||||
timestamp: isoFormatter.string(from: msg.timestamp),
|
||||
timestamp: Self.isoString(from: msg.timestamp),
|
||||
sortOrder: index,
|
||||
modelId: msg.modelId
|
||||
)
|
||||
@@ -466,7 +480,7 @@ final class DatabaseService: Sendable {
|
||||
let messages = messageRecords.compactMap { record -> Message? in
|
||||
guard let msgId = UUID(uuidString: record.id),
|
||||
let role = MessageRole(rawValue: record.role),
|
||||
let timestamp = self.isoFormatter.date(from: record.timestamp)
|
||||
let timestamp = Self.isoDate(from: record.timestamp)
|
||||
else { return nil }
|
||||
|
||||
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),
|
||||
let createdAt = self.isoFormatter.date(from: convRecord.createdAt),
|
||||
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt)
|
||||
let createdAt = Self.isoDate(from: convRecord.createdAt),
|
||||
let updatedAt = Self.isoDate(from: convRecord.updatedAt)
|
||||
else { return nil }
|
||||
|
||||
let conversation = Conversation(
|
||||
@@ -509,8 +523,8 @@ final class DatabaseService: Sendable {
|
||||
|
||||
return records.compactMap { record -> Conversation? in
|
||||
guard let id = UUID(uuidString: record.id),
|
||||
let createdAt = self.isoFormatter.date(from: record.createdAt),
|
||||
let updatedAt = self.isoFormatter.date(from: record.updatedAt)
|
||||
let createdAt = Self.isoDate(from: record.createdAt),
|
||||
let updatedAt = Self.isoDate(from: record.updatedAt)
|
||||
else { return nil }
|
||||
|
||||
// Fetch message count without loading all messages
|
||||
@@ -524,7 +538,7 @@ final class DatabaseService: Sendable {
|
||||
.order(Column("sortOrder").desc)
|
||||
.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
|
||||
let primaryModel = record.primaryModel ?? lastMsg?.modelId
|
||||
@@ -574,7 +588,7 @@ final class DatabaseService: Sendable {
|
||||
convRecord.name = name
|
||||
}
|
||||
|
||||
convRecord.updatedAt = self.isoFormatter.string(from: Date())
|
||||
convRecord.updatedAt = Self.isoString(from: Date())
|
||||
try convRecord.update(db)
|
||||
|
||||
if let messages = messages {
|
||||
@@ -589,7 +603,7 @@ final class DatabaseService: Sendable {
|
||||
content: msg.content,
|
||||
tokens: msg.tokens,
|
||||
cost: msg.cost,
|
||||
timestamp: self.isoFormatter.string(from: msg.timestamp),
|
||||
timestamp: Self.isoString(from: msg.timestamp),
|
||||
sortOrder: index
|
||||
)
|
||||
}
|
||||
@@ -610,7 +624,7 @@ final class DatabaseService: Sendable {
|
||||
let record = HistoryRecord(
|
||||
id: UUID().uuidString,
|
||||
input: input,
|
||||
timestamp: isoFormatter.string(from: now)
|
||||
timestamp: Self.isoString(from: now)
|
||||
)
|
||||
|
||||
try? dbQueue.write { db in
|
||||
@@ -643,7 +657,7 @@ final class DatabaseService: Sendable {
|
||||
.fetchAll(db)
|
||||
|
||||
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 (input: record.input, timestamp: date)
|
||||
@@ -659,7 +673,7 @@ final class DatabaseService: Sendable {
|
||||
.fetchAll(db)
|
||||
|
||||
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 (input: record.input, timestamp: date)
|
||||
@@ -672,7 +686,7 @@ final class DatabaseService: Sendable {
|
||||
nonisolated func saveEmailLog(_ log: EmailLog) {
|
||||
let record = EmailLogRecord(
|
||||
id: log.id.uuidString,
|
||||
timestamp: isoFormatter.string(from: log.timestamp),
|
||||
timestamp: Self.isoString(from: log.timestamp),
|
||||
sender: log.sender,
|
||||
subject: log.subject,
|
||||
emailContent: log.emailContent,
|
||||
@@ -698,7 +712,7 @@ final class DatabaseService: Sendable {
|
||||
.fetchAll(db)
|
||||
|
||||
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 id = UUID(uuidString: record.id) else {
|
||||
return nil
|
||||
@@ -805,7 +819,7 @@ final class DatabaseService: Sendable {
|
||||
// MARK: - Embedding Operations
|
||||
|
||||
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(
|
||||
message_id: messageId.uuidString,
|
||||
embedding: embedding,
|
||||
@@ -825,7 +839,7 @@ final class DatabaseService: Sendable {
|
||||
}
|
||||
|
||||
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(
|
||||
conversation_id: conversationId.uuidString,
|
||||
embedding: embedding,
|
||||
@@ -881,7 +895,7 @@ final class DatabaseService: Sendable {
|
||||
return Array(results.prefix(limit))
|
||||
}
|
||||
|
||||
private func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||
private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||
var embedding: [Float] = []
|
||||
embedding.reserveCapacity(data.count / 4)
|
||||
|
||||
@@ -905,7 +919,7 @@ final class DatabaseService: Sendable {
|
||||
model: String?,
|
||||
tokenCount: Int?
|
||||
) throws {
|
||||
let now = isoFormatter.string(from: Date())
|
||||
let now = Self.isoString(from: Date())
|
||||
let record = ConversationSummaryRecord(
|
||||
id: UUID().uuidString,
|
||||
conversation_id: conversationId.uuidString,
|
||||
|
||||
@@ -71,7 +71,7 @@ enum EmbeddingProvider {
|
||||
// MARK: - Embedding Service
|
||||
|
||||
final class EmbeddingService {
|
||||
static let shared = EmbeddingService()
|
||||
nonisolated static let shared = EmbeddingService()
|
||||
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
@@ -281,7 +281,7 @@ final class EmbeddingService {
|
||||
// MARK: - Similarity Calculation
|
||||
|
||||
/// 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 {
|
||||
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
|
||||
return 0.0
|
||||
|
||||
@@ -29,19 +29,18 @@ import CryptoKit
|
||||
import IOKit
|
||||
|
||||
class EncryptionService {
|
||||
static let shared = EncryptionService()
|
||||
nonisolated static let shared = EncryptionService()
|
||||
|
||||
private let salt = "oAI-secure-storage-v1" // App-specific salt
|
||||
private lazy var encryptionKey: SymmetricKey = {
|
||||
deriveEncryptionKey()
|
||||
}()
|
||||
private let encryptionKey: SymmetricKey
|
||||
|
||||
private init() {}
|
||||
private init() {
|
||||
self.encryptionKey = Self.deriveEncryptionKey()
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// 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 {
|
||||
throw EncryptionError.invalidInput
|
||||
}
|
||||
@@ -55,7 +54,7 @@ class EncryptionService {
|
||||
}
|
||||
|
||||
/// Decrypt a string value
|
||||
func decrypt(_ encryptedValue: String) throws -> String {
|
||||
nonisolated func decrypt(_ encryptedValue: String) throws -> String {
|
||||
guard let data = Data(base64Encoded: encryptedValue) else {
|
||||
throw EncryptionError.invalidInput
|
||||
}
|
||||
@@ -73,19 +72,17 @@ class EncryptionService {
|
||||
// MARK: - Key Derivation
|
||||
|
||||
/// Derive encryption key from machine-specific data
|
||||
private func deriveEncryptionKey() -> SymmetricKey {
|
||||
// Combine machine UUID + bundle ID + salt for key material
|
||||
private static func deriveEncryptionKey() -> SymmetricKey {
|
||||
let machineUUID = getMachineUUID()
|
||||
let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI"
|
||||
let salt = "oAI-secure-storage-v1"
|
||||
let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)"
|
||||
|
||||
// Hash to create consistent 256-bit key
|
||||
let hash = SHA256.hash(data: Data(keyMaterial.utf8))
|
||||
return SymmetricKey(data: hash)
|
||||
}
|
||||
|
||||
/// Get machine-specific UUID (IOPlatformUUID)
|
||||
private func getMachineUUID() -> String {
|
||||
private static func getMachineUUID() -> String {
|
||||
// Get IOPlatformUUID from IOKit
|
||||
let platformExpert = IOServiceGetMatchingService(
|
||||
kIOMainPortDefault,
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
//
|
||||
// EventKitService.swift
|
||||
// oAI
|
||||
//
|
||||
// Calendar and Reminders integration via EventKit
|
||||
//
|
||||
// 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 EventKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Shared tri-state authorization status for the Settings UI across all Personal Data services.
|
||||
/// `.denied` also covers `.restricted` and (for Calendar) `.writeOnly` — states where the OS
|
||||
/// will not show a request dialog again; the user must go to System Settings manually.
|
||||
enum PersonalDataAccessState {
|
||||
case notDetermined
|
||||
case denied
|
||||
case granted
|
||||
}
|
||||
|
||||
@Observable
|
||||
class EventKitService {
|
||||
static let shared = EventKitService()
|
||||
|
||||
private let store = EKEventStore()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
var calendarAuthStatus: EKAuthorizationStatus {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.debug("EventKitService.calendarAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
return status
|
||||
}
|
||||
|
||||
var reminderAuthStatus: EKAuthorizationStatus {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.debug("EventKitService.reminderAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
return status
|
||||
}
|
||||
|
||||
var calendarAuthorized: Bool {
|
||||
calendarAuthStatus == .fullAccess
|
||||
}
|
||||
|
||||
var reminderAuthorized: Bool {
|
||||
reminderAuthStatus == .fullAccess
|
||||
}
|
||||
|
||||
var calendarAccessState: PersonalDataAccessState {
|
||||
switch calendarAuthStatus {
|
||||
case .fullAccess: return .granted
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied // .denied, .restricted, .writeOnly (no read access for our tools)
|
||||
}
|
||||
}
|
||||
|
||||
var reminderAccessState: PersonalDataAccessState {
|
||||
switch reminderAuthStatus {
|
||||
case .fullAccess: return .granted
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestCalendarAccess() async -> Bool {
|
||||
let before = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.info("requestCalendarAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||
do {
|
||||
let granted = try await store.requestFullAccessToEvents()
|
||||
let after = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.info("requestCalendarAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return granted
|
||||
} catch {
|
||||
let after = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.error("requestCalendarAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestReminderAccess() async -> Bool {
|
||||
let before = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.info("requestReminderAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||
do {
|
||||
let granted = try await store.requestFullAccessToReminders()
|
||||
let after = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.info("requestReminderAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return granted
|
||||
} catch {
|
||||
let after = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.error("requestReminderAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func describe(_ status: EKAuthorizationStatus) -> String {
|
||||
switch status {
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
case .denied: return "denied"
|
||||
case .fullAccess: return "fullAccess"
|
||||
case .writeOnly: return "writeOnly"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool Schemas
|
||||
|
||||
func getToolSchemas(calendarEnabled: Bool, remindersEnabled: Bool) -> [Tool] {
|
||||
var tools: [Tool] = []
|
||||
|
||||
if calendarEnabled {
|
||||
tools.append(makeTool(
|
||||
name: "calendar_list_calendars",
|
||||
description: "List all calendars available on this Mac (e.g. iCloud, Work, Home).",
|
||||
properties: [:],
|
||||
required: []
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "calendar_list_events",
|
||||
description: "List calendar events within a date range. Dates are ISO8601 (e.g. '2026-06-20T00:00:00' or '2026-06-20'). Range is limited to 1 year. For open-ended queries like 'next appointment' or 'upcoming events', do NOT limit the range to just today — use a generous forward-looking window (e.g. today through +90 days) so you don't miss events further out.",
|
||||
properties: [
|
||||
"start_date": prop("string", "Start of the date range (ISO8601)"),
|
||||
"end_date": prop("string", "End of the date range (ISO8601)"),
|
||||
"calendar_name": prop("string", "Optional: only list events from this calendar")
|
||||
],
|
||||
required: ["start_date", "end_date"]
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "calendar_create_event",
|
||||
description: "Create a new calendar event. Requires user approval before it is actually created.",
|
||||
properties: [
|
||||
"title": prop("string", "Event title"),
|
||||
"start_date": prop("string", "Start date/time (ISO8601, e.g. '2026-06-20T14:00:00')"),
|
||||
"end_date": prop("string", "End date/time (ISO8601)"),
|
||||
"calendar_name": prop("string", "Optional: calendar to add the event to (defaults to the system default calendar)"),
|
||||
"location": prop("string", "Optional: event location text"),
|
||||
"notes": prop("string", "Optional: event notes"),
|
||||
"all_day": prop("boolean", "Optional: whether this is an all-day event (default: false)"),
|
||||
"alarm_minutes_before": prop("number", "Optional: minutes before the start time to show an alert")
|
||||
],
|
||||
required: ["title", "start_date", "end_date"]
|
||||
))
|
||||
}
|
||||
|
||||
if remindersEnabled {
|
||||
tools.append(makeTool(
|
||||
name: "reminders_list_lists",
|
||||
description: "List all reminder lists available on this Mac.",
|
||||
properties: [:],
|
||||
required: []
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "reminders_list",
|
||||
description: "List reminders. Omit list_name to search across ALL reminder lists in a single call — prefer this over calling once per list. Incomplete reminders only unless include_completed is true.",
|
||||
properties: [
|
||||
"list_name": prop("string", "Optional: only list reminders from this one list (omit to search all lists at once)"),
|
||||
"include_completed": prop("boolean", "Optional: include completed reminders (default: false)")
|
||||
],
|
||||
required: []
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "reminders_create",
|
||||
description: "Create a new reminder. Requires user approval before it is actually created.",
|
||||
properties: [
|
||||
"title": prop("string", "Reminder title"),
|
||||
"list_name": prop("string", "Optional: reminder list to add to (defaults to the system default list)"),
|
||||
"due_date": prop("string", "Optional: due date/time (ISO8601)"),
|
||||
"priority": prop("string", "Optional: priority level", enumValues: ["none", "low", "medium", "high"]),
|
||||
"notes": prop("string", "Optional: reminder notes")
|
||||
],
|
||||
required: ["title"]
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "reminders_complete",
|
||||
description: "Mark a reminder as completed. Requires user approval. Use reminders_list to find the reminder_id first.",
|
||||
properties: [
|
||||
"reminder_id": prop("string", "The reminder's identifier, from reminders_list")
|
||||
],
|
||||
required: ["reminder_id"]
|
||||
))
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
// MARK: - Read Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||
Log.mcp.info("Executing EventKit tool: \(name)")
|
||||
let args = Self.parseArgs(arguments)
|
||||
|
||||
switch name {
|
||||
case "calendar_list_calendars":
|
||||
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||
return listCalendars()
|
||||
|
||||
case "calendar_list_events":
|
||||
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
|
||||
return ["error": "Missing or invalid parameter: start_date"]
|
||||
}
|
||||
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
|
||||
return ["error": "Missing or invalid parameter: end_date"]
|
||||
}
|
||||
let calendarName = args["calendar_name"] as? String
|
||||
return listEvents(start: start, end: end, calendarName: calendarName)
|
||||
|
||||
case "reminders_list_lists":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
return listReminderLists()
|
||||
|
||||
case "reminders_list":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
let listName = args["list_name"] as? String
|
||||
let includeCompleted = args["include_completed"] as? Bool ?? false
|
||||
return await listReminders(listName: listName, includeCompleted: includeCompleted)
|
||||
|
||||
default:
|
||||
return ["error": "Unknown EventKit tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Write Tool Execution (called only after approval)
|
||||
|
||||
func executeWriteTool(name: String, arguments: String) async -> [String: Any] {
|
||||
Log.mcp.info("Executing EventKit write tool: \(name)")
|
||||
let args = Self.parseArgs(arguments)
|
||||
|
||||
switch name {
|
||||
case "calendar_create_event":
|
||||
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||
return createEvent(args: args)
|
||||
|
||||
case "reminders_create":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
return createReminder(args: args)
|
||||
|
||||
case "reminders_complete":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
guard let reminderId = args["reminder_id"] as? String else {
|
||||
return ["error": "Missing required parameter: reminder_id"]
|
||||
}
|
||||
return completeReminder(reminderId: reminderId)
|
||||
|
||||
default:
|
||||
return ["error": "Unknown EventKit write tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Approval Summary
|
||||
|
||||
/// Human-readable description shown in the approval sheet before a write tool runs.
|
||||
func approvalSummary(forTool name: String, arguments: String) -> String {
|
||||
let args = Self.parseArgs(arguments)
|
||||
switch name {
|
||||
case "calendar_create_event":
|
||||
let title = args["title"] as? String ?? "Untitled event"
|
||||
let start = (args["start_date"] as? String).flatMap(Self.parseDate) ?? Date()
|
||||
let end = (args["end_date"] as? String).flatMap(Self.parseDate) ?? start
|
||||
return "Create calendar event \"\(title)\" from \(Self.displayFormatter.string(from: start)) to \(Self.displayFormatter.string(from: end))"
|
||||
case "reminders_create":
|
||||
let title = args["title"] as? String ?? "Untitled reminder"
|
||||
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
|
||||
return "Create reminder \"\(title)\" due \(Self.displayFormatter.string(from: due))"
|
||||
}
|
||||
return "Create reminder \"\(title)\""
|
||||
case "reminders_complete":
|
||||
return "Mark reminder as completed"
|
||||
default:
|
||||
return "Perform action: \(name)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar Read Implementations
|
||||
|
||||
private func listCalendars() -> [String: Any] {
|
||||
let calendars = store.calendars(for: .event).map { cal -> [String: Any] in
|
||||
[
|
||||
"name": cal.title,
|
||||
"type": calendarTypeDescription(cal),
|
||||
"allows_modifications": cal.allowsContentModifications
|
||||
]
|
||||
}
|
||||
return ["calendars": calendars]
|
||||
}
|
||||
|
||||
private func listEvents(start: Date, end: Date, calendarName: String?) -> [String: Any] {
|
||||
guard end > start else { return ["error": "end_date must be after start_date"] }
|
||||
guard end.timeIntervalSince(start) <= 366 * 24 * 60 * 60 else {
|
||||
return ["error": "Date range too large — limit to 1 year or less"]
|
||||
}
|
||||
|
||||
var calendars = store.calendars(for: .event)
|
||||
if let calendarName {
|
||||
calendars = calendars.filter { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }
|
||||
if calendars.isEmpty {
|
||||
return ["error": "No calendar found named '\(calendarName)'"]
|
||||
}
|
||||
}
|
||||
|
||||
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
|
||||
let events = store.events(matching: predicate)
|
||||
.sorted { $0.startDate < $1.startDate }
|
||||
.prefix(200)
|
||||
.map { event -> [String: Any] in
|
||||
var item: [String: Any] = [
|
||||
"id": event.eventIdentifier ?? "",
|
||||
"title": event.title ?? "Untitled",
|
||||
"start": Self.isoFormatter.string(from: event.startDate),
|
||||
"end": Self.isoFormatter.string(from: event.endDate),
|
||||
"all_day": event.isAllDay,
|
||||
"calendar": event.calendar?.title ?? ""
|
||||
]
|
||||
if let location = event.location, !location.isEmpty {
|
||||
item["location"] = location
|
||||
}
|
||||
if let notes = event.notes, !notes.isEmpty {
|
||||
item["notes"] = String(notes.prefix(500))
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
return ["count": events.count, "events": Array(events)]
|
||||
}
|
||||
|
||||
private func createEvent(args: [String: Any]) -> [String: Any] {
|
||||
guard let title = args["title"] as? String, !title.isEmpty else {
|
||||
return ["error": "Missing required parameter: title"]
|
||||
}
|
||||
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
|
||||
return ["error": "Missing or invalid parameter: start_date"]
|
||||
}
|
||||
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
|
||||
return ["error": "Missing or invalid parameter: end_date"]
|
||||
}
|
||||
guard end >= start else {
|
||||
return ["error": "end_date must not be before start_date"]
|
||||
}
|
||||
|
||||
let event = EKEvent(eventStore: store)
|
||||
event.title = title
|
||||
event.startDate = start
|
||||
event.endDate = end
|
||||
event.isAllDay = args["all_day"] as? Bool ?? false
|
||||
|
||||
if let calendarName = args["calendar_name"] as? String,
|
||||
let calendar = store.calendars(for: .event).first(where: { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }) {
|
||||
event.calendar = calendar
|
||||
} else if let defaultCalendar = store.defaultCalendarForNewEvents {
|
||||
event.calendar = defaultCalendar
|
||||
} else {
|
||||
guard let fallback = store.calendars(for: .event).first(where: { $0.allowsContentModifications }) else {
|
||||
return ["error": "No writable calendar available"]
|
||||
}
|
||||
event.calendar = fallback
|
||||
}
|
||||
|
||||
if let location = args["location"] as? String { event.location = location }
|
||||
if let notes = args["notes"] as? String { event.notes = notes }
|
||||
if let minutesBefore = (args["alarm_minutes_before"] as? Double) ?? (args["alarm_minutes_before"] as? Int).map(Double.init) {
|
||||
event.addAlarm(EKAlarm(relativeOffset: -(minutesBefore * 60)))
|
||||
}
|
||||
|
||||
do {
|
||||
try store.save(event, span: .thisEvent, commit: true)
|
||||
return ["success": true, "event_id": event.eventIdentifier ?? "", "calendar": event.calendar?.title ?? ""]
|
||||
} catch {
|
||||
Log.mcp.error("calendar_create_event failed: \(error.localizedDescription)")
|
||||
return ["error": "Failed to create event: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reminders Read Implementations
|
||||
|
||||
private func listReminderLists() -> [String: Any] {
|
||||
let lists = store.calendars(for: .reminder).map { cal -> [String: Any] in
|
||||
["name": cal.title, "allows_modifications": cal.allowsContentModifications]
|
||||
}
|
||||
return ["lists": lists]
|
||||
}
|
||||
|
||||
private func listReminders(listName: String?, includeCompleted: Bool) async -> [String: Any] {
|
||||
var lists = store.calendars(for: .reminder)
|
||||
if let listName {
|
||||
lists = lists.filter { $0.title.caseInsensitiveCompare(listName) == .orderedSame }
|
||||
if lists.isEmpty {
|
||||
return ["error": "No reminder list found named '\(listName)'"]
|
||||
}
|
||||
}
|
||||
|
||||
let predicate = store.predicateForReminders(in: lists)
|
||||
let reminders: [EKReminder] = await withCheckedContinuation { continuation in
|
||||
store.fetchReminders(matching: predicate) { results in
|
||||
continuation.resume(returning: results ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
let filtered = reminders
|
||||
.filter { includeCompleted || !$0.isCompleted }
|
||||
.sorted { lhs, rhs in
|
||||
let l = lhs.dueDateComponents?.date ?? .distantFuture
|
||||
let r = rhs.dueDateComponents?.date ?? .distantFuture
|
||||
return l < r
|
||||
}
|
||||
.prefix(200)
|
||||
.map { reminder -> [String: Any] in
|
||||
var item: [String: Any] = [
|
||||
"id": reminder.calendarItemIdentifier,
|
||||
"title": reminder.title ?? "Untitled",
|
||||
"completed": reminder.isCompleted,
|
||||
"list": reminder.calendar?.title ?? ""
|
||||
]
|
||||
if let due = reminder.dueDateComponents?.date {
|
||||
item["due"] = Self.isoFormatter.string(from: due)
|
||||
}
|
||||
if reminder.priority > 0 {
|
||||
item["priority"] = priorityDescription(reminder.priority)
|
||||
}
|
||||
if let notes = reminder.notes, !notes.isEmpty {
|
||||
item["notes"] = String(notes.prefix(500))
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
return ["count": filtered.count, "reminders": Array(filtered)]
|
||||
}
|
||||
|
||||
private func createReminder(args: [String: Any]) -> [String: Any] {
|
||||
guard let title = args["title"] as? String, !title.isEmpty else {
|
||||
return ["error": "Missing required parameter: title"]
|
||||
}
|
||||
|
||||
let reminder = EKReminder(eventStore: store)
|
||||
reminder.title = title
|
||||
|
||||
if let listName = args["list_name"] as? String,
|
||||
let list = store.calendars(for: .reminder).first(where: { $0.title.caseInsensitiveCompare(listName) == .orderedSame }) {
|
||||
reminder.calendar = list
|
||||
} else if let defaultList = store.defaultCalendarForNewReminders() {
|
||||
reminder.calendar = defaultList
|
||||
} else {
|
||||
guard let fallback = store.calendars(for: .reminder).first(where: { $0.allowsContentModifications }) else {
|
||||
return ["error": "No writable reminder list available"]
|
||||
}
|
||||
reminder.calendar = fallback
|
||||
}
|
||||
|
||||
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
|
||||
reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: due)
|
||||
}
|
||||
if let notes = args["notes"] as? String { reminder.notes = notes }
|
||||
if let priority = args["priority"] as? String { reminder.priority = priorityValue(priority) }
|
||||
|
||||
do {
|
||||
try store.save(reminder, commit: true)
|
||||
return ["success": true, "reminder_id": reminder.calendarItemIdentifier, "list": reminder.calendar?.title ?? ""]
|
||||
} catch {
|
||||
Log.mcp.error("reminders_create failed: \(error.localizedDescription)")
|
||||
return ["error": "Failed to create reminder: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
private func completeReminder(reminderId: String) -> [String: Any] {
|
||||
guard let item = store.calendarItem(withIdentifier: reminderId) as? EKReminder else {
|
||||
return ["error": "No reminder found with id '\(reminderId)'"]
|
||||
}
|
||||
item.isCompleted = true
|
||||
item.completionDate = Date()
|
||||
|
||||
do {
|
||||
try store.save(item, commit: true)
|
||||
return ["success": true, "reminder_id": reminderId, "title": item.title ?? ""]
|
||||
} catch {
|
||||
Log.mcp.error("reminders_complete failed: \(error.localizedDescription)")
|
||||
return ["error": "Failed to complete reminder: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func calendarTypeDescription(_ cal: EKCalendar) -> String {
|
||||
switch cal.type {
|
||||
case .local: return "local"
|
||||
case .calDAV: return "caldav"
|
||||
case .exchange: return "exchange"
|
||||
case .subscription: return "subscription"
|
||||
case .birthday: return "birthday"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private func priorityDescription(_ value: Int) -> String {
|
||||
switch value {
|
||||
case 1...4: return "high"
|
||||
case 5: return "medium"
|
||||
case 6...9: return "low"
|
||||
default: return "none"
|
||||
}
|
||||
}
|
||||
|
||||
private func priorityValue(_ description: String) -> Int {
|
||||
switch description.lowercased() {
|
||||
case "high": return 1
|
||||
case "medium": return 5
|
||||
case "low": return 9
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||
Tool(
|
||||
type: "function",
|
||||
function: Tool.Function(
|
||||
name: name,
|
||||
description: description,
|
||||
parameters: Tool.Function.Parameters(
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func prop(_ type: String, _ description: String, enumValues: [String]? = nil) -> Tool.Function.Parameters.Property {
|
||||
Tool.Function.Parameters.Property(type: type, description: description, enum: enumValues)
|
||||
}
|
||||
|
||||
nonisolated static func parseArgs(_ arguments: String) -> [String: Any] {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return [:]
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
nonisolated static func permissionError(domain: String) -> [String: Any] {
|
||||
["error": "\(domain) permission not granted. Grant access in Settings > MCP."]
|
||||
}
|
||||
|
||||
nonisolated(unsafe) static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
nonisolated static let displayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
|
||||
nonisolated static func parseDate(_ string: String) -> Date? {
|
||||
if let date = isoFormatter.date(from: string) { return date }
|
||||
|
||||
let isoNoTimezone = ISO8601DateFormatter()
|
||||
isoNoTimezone.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = isoNoTimezone.date(from: string) { return date }
|
||||
|
||||
let localDateTime = DateFormatter()
|
||||
localDateTime.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
if let date = localDateTime.date(from: string) { return date }
|
||||
|
||||
let dateOnly = DateFormatter()
|
||||
dateOnly.dateFormat = "yyyy-MM-dd"
|
||||
if let date = dateOnly.date(from: string) { return date }
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ class GitSyncService {
|
||||
|
||||
// Check if conversation already exists (by 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
|
||||
log.debug("Skipping existing conversation: \(export.name)")
|
||||
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? }
|
||||
@@ -0,0 +1,343 @@
|
||||
//
|
||||
// LocationMapsService.swift
|
||||
// oAI
|
||||
//
|
||||
// Read-only Location and Maps integration via CoreLocation and MapKit
|
||||
//
|
||||
// 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 CoreLocation
|
||||
import Foundation
|
||||
import MapKit
|
||||
import os
|
||||
|
||||
@Observable
|
||||
class LocationMapsService: NSObject, CLLocationManagerDelegate {
|
||||
static let shared = LocationMapsService()
|
||||
|
||||
private let locationManager = CLLocationManager()
|
||||
|
||||
private var authContinuation: CheckedContinuation<Bool, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation?, Never>?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
locationManager.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
var authStatus: CLAuthorizationStatus {
|
||||
locationManager.authorizationStatus
|
||||
}
|
||||
|
||||
var authorized: Bool {
|
||||
authStatus == .authorizedAlways || authStatus == .authorized
|
||||
}
|
||||
|
||||
var accessState: PersonalDataAccessState {
|
||||
let status = authStatus
|
||||
Log.mcp.debug("LocationMapsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
switch status {
|
||||
case .authorizedAlways, .authorized: return .granted
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestAccess() async -> Bool {
|
||||
let before = locationManager.authorizationStatus
|
||||
Log.mcp.info("LocationMapsService.requestAccess: status before = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||
if before != .notDetermined {
|
||||
Log.mcp.info("LocationMapsService.requestAccess: skipping OS prompt (not notDetermined)")
|
||||
return authorized
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
self.authContinuation = continuation
|
||||
locationManager.requestWhenInUseAuthorization()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Log.mcp.info("LocationMapsService: authorization changed -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
authContinuation?.resume(returning: authorized)
|
||||
authContinuation = nil
|
||||
}
|
||||
|
||||
nonisolated static func describe(_ status: CLAuthorizationStatus) -> String {
|
||||
switch status {
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
case .denied: return "denied"
|
||||
case .authorizedAlways: return "authorizedAlways"
|
||||
case .authorized: return "authorized"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
locationContinuation?.resume(returning: locations.last)
|
||||
locationContinuation = nil
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
Log.mcp.error("Location request failed: \(error.localizedDescription)")
|
||||
locationContinuation?.resume(returning: nil)
|
||||
locationContinuation = nil
|
||||
}
|
||||
|
||||
private func currentLocation() async -> CLLocation? {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.locationContinuation = continuation
|
||||
locationManager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool Schemas
|
||||
|
||||
func getToolSchemas() -> [Tool] {
|
||||
[
|
||||
makeTool(
|
||||
name: "location_get_current",
|
||||
description: "Get the device's current location, including a human-readable address.",
|
||||
properties: [:],
|
||||
required: []
|
||||
),
|
||||
makeTool(
|
||||
name: "maps_search_places",
|
||||
description: "Search for places (businesses, landmarks, addresses) by name or category.",
|
||||
properties: [
|
||||
"query": prop("string", "What to search for, e.g. 'coffee shops' or 'Eiffel Tower'"),
|
||||
"near": prop("string", "Optional: an address or 'latitude,longitude' to search near")
|
||||
],
|
||||
required: ["query"]
|
||||
),
|
||||
makeTool(
|
||||
name: "maps_geocode",
|
||||
description: "Convert an address into geographic coordinates and a formatted address.",
|
||||
properties: [
|
||||
"address": prop("string", "The address to geocode")
|
||||
],
|
||||
required: ["address"]
|
||||
),
|
||||
makeTool(
|
||||
name: "maps_get_directions",
|
||||
description: "Get distance and estimated travel time between two locations.",
|
||||
properties: [
|
||||
"origin": prop("string", "Starting address or 'latitude,longitude'"),
|
||||
"destination": prop("string", "Destination address or 'latitude,longitude'"),
|
||||
"transport_type": prop("string", "Mode of transport", enumValues: ["driving", "walking", "transit"])
|
||||
],
|
||||
required: ["origin", "destination"]
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||
Log.mcp.info("Executing LocationMaps tool: \(name)")
|
||||
let args = parseArgs(arguments)
|
||||
|
||||
switch name {
|
||||
case "location_get_current":
|
||||
guard authorized else { return ["error": "Location permission not granted. Grant access in Settings > MCP."] }
|
||||
return await getCurrentLocation()
|
||||
|
||||
case "maps_search_places":
|
||||
guard let query = args["query"] as? String, !query.isEmpty else {
|
||||
return ["error": "Missing required parameter: query"]
|
||||
}
|
||||
let near = args["near"] as? String
|
||||
return await searchPlaces(query: query, near: near)
|
||||
|
||||
case "maps_geocode":
|
||||
guard let address = args["address"] as? String, !address.isEmpty else {
|
||||
return ["error": "Missing required parameter: address"]
|
||||
}
|
||||
return await geocode(address: address)
|
||||
|
||||
case "maps_get_directions":
|
||||
guard let origin = args["origin"] as? String, let destination = args["destination"] as? String else {
|
||||
return ["error": "Missing required parameter: origin and/or destination"]
|
||||
}
|
||||
let transportType = args["transport_type"] as? String ?? "driving"
|
||||
return await getDirections(origin: origin, destination: destination, transportType: transportType)
|
||||
|
||||
default:
|
||||
return ["error": "Unknown LocationMaps tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Implementations
|
||||
|
||||
private func getCurrentLocation() async -> [String: Any] {
|
||||
guard let location = await currentLocation() else {
|
||||
return ["error": "Could not determine current location"]
|
||||
}
|
||||
var result: [String: Any] = [
|
||||
"latitude": location.coordinate.latitude,
|
||||
"longitude": location.coordinate.longitude
|
||||
]
|
||||
if let mapItem = await reverseGeocode(location), let address = addressString(for: mapItem) {
|
||||
result["address"] = address
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func searchPlaces(query: String, near: String?) async -> [String: Any] {
|
||||
let request = MKLocalSearch.Request()
|
||||
request.naturalLanguageQuery = query
|
||||
if let near, let coordinate = await coordinate(for: near) {
|
||||
request.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 20_000, longitudinalMeters: 20_000)
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await MKLocalSearch(request: request).start()
|
||||
let places = response.mapItems.prefix(15).map { item -> [String: Any] in
|
||||
var place: [String: Any] = ["name": item.name ?? "Unknown"]
|
||||
place["latitude"] = item.location.coordinate.latitude
|
||||
place["longitude"] = item.location.coordinate.longitude
|
||||
if let address = addressString(for: item) {
|
||||
place["address"] = address
|
||||
}
|
||||
if let phone = item.phoneNumber { place["phone"] = phone }
|
||||
return place
|
||||
}
|
||||
return ["count": places.count, "places": Array(places)]
|
||||
} catch {
|
||||
return ["error": "Search failed: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
private func geocode(address: String) async -> [String: Any] {
|
||||
guard let request = MKGeocodingRequest(addressString: address) else {
|
||||
return ["error": "Invalid address: \(address)"]
|
||||
}
|
||||
do {
|
||||
guard let mapItem = try await request.mapItems.first else {
|
||||
return ["error": "No results found for address: \(address)"]
|
||||
}
|
||||
var result: [String: Any] = [
|
||||
"latitude": mapItem.location.coordinate.latitude,
|
||||
"longitude": mapItem.location.coordinate.longitude
|
||||
]
|
||||
if let formatted = addressString(for: mapItem) {
|
||||
result["formatted_address"] = formatted
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return ["error": "Geocoding failed: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
private func getDirections(origin: String, destination: String, transportType: String) async -> [String: Any] {
|
||||
guard let originCoordinate = await coordinate(for: origin) else {
|
||||
return ["error": "Could not resolve origin: \(origin)"]
|
||||
}
|
||||
guard let destinationCoordinate = await coordinate(for: destination) else {
|
||||
return ["error": "Could not resolve destination: \(destination)"]
|
||||
}
|
||||
|
||||
let request = MKDirections.Request()
|
||||
request.source = MKMapItem(location: CLLocation(latitude: originCoordinate.latitude, longitude: originCoordinate.longitude), address: nil)
|
||||
request.destination = MKMapItem(location: CLLocation(latitude: destinationCoordinate.latitude, longitude: destinationCoordinate.longitude), address: nil)
|
||||
switch transportType {
|
||||
case "walking": request.transportType = .walking
|
||||
case "transit": request.transportType = .transit
|
||||
default: request.transportType = .automobile
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await MKDirections(request: request).calculate()
|
||||
guard let route = response.routes.first else {
|
||||
return ["error": "No route found"]
|
||||
}
|
||||
let distanceFormatter = MKDistanceFormatter()
|
||||
let durationFormatter = DateComponentsFormatter()
|
||||
durationFormatter.allowedUnits = [.hour, .minute]
|
||||
durationFormatter.unitsStyle = .short
|
||||
|
||||
return [
|
||||
"distance_meters": route.distance,
|
||||
"distance_text": distanceFormatter.string(fromDistance: route.distance),
|
||||
"duration_seconds": route.expectedTravelTime,
|
||||
"duration_text": durationFormatter.string(from: route.expectedTravelTime) ?? "",
|
||||
"transport_type": transportType
|
||||
]
|
||||
} catch {
|
||||
return ["error": "Directions failed: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func coordinate(for text: String) async -> CLLocationCoordinate2D? {
|
||||
let parts = text.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
if parts.count == 2, let lat = Double(parts[0]), let lon = Double(parts[1]) {
|
||||
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||||
}
|
||||
guard let request = MKGeocodingRequest(addressString: text) else { return nil }
|
||||
if let mapItems = try? await request.mapItems, let mapItem = mapItems.first {
|
||||
return mapItem.location.coordinate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func reverseGeocode(_ location: CLLocation) async -> MKMapItem? {
|
||||
guard let request = MKReverseGeocodingRequest(location: location) else { return nil }
|
||||
let mapItems = try? await request.mapItems
|
||||
return mapItems?.first
|
||||
}
|
||||
|
||||
private func addressString(for mapItem: MKMapItem) -> String? {
|
||||
mapItem.address?.fullAddress
|
||||
?? mapItem.addressRepresentations?.fullAddress(includingRegion: true, singleLine: true)
|
||||
}
|
||||
|
||||
private func parseArgs(_ arguments: String) -> [String: Any] {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return [:]
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||
Tool(
|
||||
type: "function",
|
||||
function: Tool.Function(
|
||||
name: name,
|
||||
description: description,
|
||||
parameters: Tool.Function.Parameters(
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func prop(_ type: String, _ description: String, enumValues: [String]? = nil) -> Tool.Function.Parameters.Property {
|
||||
Tool.Function.Parameters.Property(type: type, description: description, enum: enumValues)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,9 @@ class MCPService {
|
||||
|
||||
private let anytypeService = AnytypeMCPService.shared
|
||||
private let paperlessService = PaperlessService.shared
|
||||
private let eventKitService = EventKitService.shared
|
||||
private let contactsService = ContactsService.shared
|
||||
private let locationMapsService = LocationMapsService.shared
|
||||
|
||||
// MARK: - Bash Approval State
|
||||
|
||||
@@ -124,6 +127,19 @@ class MCPService {
|
||||
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
||||
private(set) var bashSessionApproved: Bool = false
|
||||
|
||||
// MARK: - Personal Data (Calendar/Reminders) Approval State
|
||||
|
||||
struct PendingPersonalDataAction: Identifiable {
|
||||
let id = UUID()
|
||||
let toolName: String
|
||||
let argumentsJSON: String
|
||||
let summary: String
|
||||
}
|
||||
|
||||
private(set) var pendingPersonalDataAction: PendingPersonalDataAction? = nil
|
||||
private var pendingPersonalDataContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
||||
private(set) var personalDataSessionApproved: Bool = false
|
||||
|
||||
// MARK: - Tool Schema Generation
|
||||
|
||||
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
|
||||
@@ -232,6 +248,24 @@ class MCPService {
|
||||
tools.append(contentsOf: paperlessService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add Calendar/Reminders tools if enabled
|
||||
if settings.calendarEnabled || settings.remindersEnabled {
|
||||
tools.append(contentsOf: eventKitService.getToolSchemas(
|
||||
calendarEnabled: settings.calendarEnabled,
|
||||
remindersEnabled: settings.remindersEnabled
|
||||
))
|
||||
}
|
||||
|
||||
// Add Contacts tools if enabled
|
||||
if settings.contactsEnabled {
|
||||
tools.append(contentsOf: contactsService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add Location/Maps tools if enabled
|
||||
if settings.locationMapsEnabled {
|
||||
tools.append(contentsOf: locationMapsService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add bash_execute tool when bash is enabled
|
||||
if settings.bashEnabled {
|
||||
let workDir = settings.bashWorkingDirectory
|
||||
@@ -260,6 +294,22 @@ class MCPService {
|
||||
))
|
||||
}
|
||||
|
||||
// Add spawn_research_agents when Research Agents are enabled
|
||||
if settings.agentsEnabled {
|
||||
tools.append(makeTool(
|
||||
name: "spawn_research_agents",
|
||||
description: "Spawn multiple READ-ONLY research sub-agents that investigate independent questions IN PARALLEL. Each sub-agent gets its own read_file/list_directory/search_files/web_search loop — no write, no bash, no nesting (sub-agents cannot call this tool). ONLY use this when you have 2 or more genuinely independent research questions that benefit from running at the same time (e.g. comparing several unrelated files, topics, or sources). Do NOT use this for a single lookup, a sequential task, or anything answerable with one direct tool call — call read_file/search_files/web_search yourself instead. Each sub-agent is its own full chain of model calls and meaningfully increases cost and latency; using this for trivial tasks is wasteful. Prefer the smallest number of tasks that actually need to run in parallel.",
|
||||
properties: [
|
||||
"tasks": Tool.Function.Parameters.Property(
|
||||
type: "array",
|
||||
description: "List of independent, self-contained research questions — one per sub-agent. Keep this list as short as the task genuinely requires.",
|
||||
items: .init(type: "string")
|
||||
)
|
||||
],
|
||||
required: ["tasks"]
|
||||
))
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -284,7 +334,10 @@ class MCPService {
|
||||
|
||||
// MARK: - Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||
/// `agentProvider`/`agentModelId` are only needed for `spawn_research_agents`, which drives
|
||||
/// its own model calls — every other tool ignores them. Passed in by the caller's active
|
||||
/// chat session rather than stored on MCPService, since this service has no provider state.
|
||||
func executeTool(name: String, arguments: String, agentProvider: AIProvider? = nil, agentModelId: String? = nil) async -> [String: Any] {
|
||||
Log.mcp.info("Executing tool: \(name)")
|
||||
guard let argData = arguments.data(using: .utf8),
|
||||
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
|
||||
@@ -399,6 +452,25 @@ class MCPService {
|
||||
let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] }
|
||||
return ["results": mapped]
|
||||
|
||||
case "spawn_research_agents":
|
||||
guard settings.agentsEnabled else {
|
||||
return ["error": "Research agents are disabled. Enable 'Research Agents' in Settings > MCP."]
|
||||
}
|
||||
guard let agentProvider, let agentModelId else {
|
||||
return ["error": "Internal error: missing model context for spawn_research_agents"]
|
||||
}
|
||||
guard let tasks = args["tasks"] as? [String], !tasks.isEmpty else {
|
||||
return ["error": "Missing required parameter: tasks (non-empty array of strings)"]
|
||||
}
|
||||
return await runResearchAgents(tasks: tasks, provider: agentProvider, modelId: agentModelId)
|
||||
|
||||
case "calendar_create_event", "reminders_create", "reminders_complete":
|
||||
guard settings.calendarEnabled || settings.remindersEnabled else {
|
||||
return ["error": "Calendar/Reminders access is disabled. Enable it in Settings > MCP."]
|
||||
}
|
||||
let summary = eventKitService.approvalSummary(forTool: name, arguments: arguments)
|
||||
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
|
||||
|
||||
default:
|
||||
// Route anytype_* tools to AnytypeMCPService
|
||||
if name.hasPrefix("anytype_") {
|
||||
@@ -408,6 +480,18 @@ class MCPService {
|
||||
if name.hasPrefix("paperless_") {
|
||||
return await paperlessService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
// Route calendar_*/reminders_* read tools to EventKitService
|
||||
if name.hasPrefix("calendar_") || name.hasPrefix("reminders_") {
|
||||
return await eventKitService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
// Route contacts_* tools to ContactsService
|
||||
if name.hasPrefix("contacts_") {
|
||||
return await contactsService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
// Route location_*/maps_* tools to LocationMapsService
|
||||
if name.hasPrefix("location_") || name.hasPrefix("maps_") {
|
||||
return await locationMapsService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
return ["error": "Unknown tool: \(name)"]
|
||||
}
|
||||
}
|
||||
@@ -754,6 +838,11 @@ class MCPService {
|
||||
if bashSessionApproved {
|
||||
return await runBashCommand(command, workingDirectory: workingDirectory)
|
||||
}
|
||||
// 2nd Brain calls its helper script via bash_execute — let the user mark that
|
||||
// specific traffic as always-trusted instead of approving it every time.
|
||||
if isTrustedSecondBrainCommand(command) {
|
||||
return await runBashCommand(command, workingDirectory: workingDirectory)
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
|
||||
@@ -786,6 +875,190 @@ class MCPService {
|
||||
bashSessionApproved = false
|
||||
}
|
||||
|
||||
private func isTrustedSecondBrainCommand(_ command: String) -> Bool {
|
||||
guard settings.trustSecondBrainSkill, command.contains(".brain_helper.py") else { return false }
|
||||
return settings.agentSkills.contains { $0.isActive && $0.isSecondBrainSkill }
|
||||
}
|
||||
|
||||
// MARK: - Research Agents (read-only, parallel)
|
||||
|
||||
/// Runs `tasks.count` sub-agents (capped) with bounded concurrency, each in its own
|
||||
/// read-only tool loop, and returns their findings concatenated for the orchestrator.
|
||||
private func runResearchAgents(tasks: [String], provider: AIProvider, modelId: String) async -> [String: Any] {
|
||||
let maxConcurrent = max(1, min(5, settings.maxConcurrentAgents))
|
||||
// Hard cap on total sub-agents regardless of concurrency setting, so a model
|
||||
// requesting an unreasonably long task list can't run away with cost.
|
||||
let cappedTasks = Array(tasks.prefix(8))
|
||||
|
||||
var results: [(Int, String)] = []
|
||||
await withTaskGroup(of: (Int, String).self) { group in
|
||||
var nextIndex = 0
|
||||
func launchNext() {
|
||||
guard nextIndex < cappedTasks.count else { return }
|
||||
let idx = nextIndex
|
||||
let task = cappedTasks[idx]
|
||||
nextIndex += 1
|
||||
group.addTask {
|
||||
let answer = await self.runSingleResearchAgent(task: task, provider: provider, modelId: modelId)
|
||||
return (idx, answer)
|
||||
}
|
||||
}
|
||||
for _ in 0..<min(maxConcurrent, cappedTasks.count) { launchNext() }
|
||||
for await result in group {
|
||||
results.append(result)
|
||||
launchNext()
|
||||
}
|
||||
}
|
||||
|
||||
let sorted = results.sorted { $0.0 < $1.0 }
|
||||
let formatted = sorted.map { idx, answer in
|
||||
"### Agent \(idx + 1): \(cappedTasks[idx])\n\(answer)"
|
||||
}.joined(separator: "\n\n")
|
||||
|
||||
var response: [String: Any] = ["agent_count": sorted.count, "results": formatted]
|
||||
if tasks.count > cappedTasks.count {
|
||||
response["note"] = "Only the first \(cappedTasks.count) of \(tasks.count) requested tasks were run (per-call cap)."
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/// A single sub-agent's self-contained tool loop. Read-only tools only; cannot write,
|
||||
/// run bash, or spawn further sub-agents (spawn_research_agents is not in its tool list).
|
||||
private func runSingleResearchAgent(task: String, provider: AIProvider, modelId: String) async -> String {
|
||||
let readOnlyTools: [Tool] = [
|
||||
makeTool(
|
||||
name: "read_file",
|
||||
description: "Read the contents of a file. Maximum file size is 10MB.",
|
||||
properties: ["file_path": prop("string", "The absolute path to the file to read")],
|
||||
required: ["file_path"]
|
||||
),
|
||||
makeTool(
|
||||
name: "list_directory",
|
||||
description: "List the contents of a directory. Skips hidden/build directories like .git, node_modules, etc.",
|
||||
properties: [
|
||||
"dir_path": prop("string", "The absolute path to the directory to list"),
|
||||
"recursive": prop("boolean", "Whether to list recursively (default: false)")
|
||||
],
|
||||
required: ["dir_path"]
|
||||
),
|
||||
makeTool(
|
||||
name: "search_files",
|
||||
description: "Search for files by name pattern or content.",
|
||||
properties: [
|
||||
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
|
||||
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
|
||||
"content_search": prop("string", "Optional text to search for inside files")
|
||||
],
|
||||
required: ["pattern"]
|
||||
),
|
||||
makeTool(
|
||||
name: "web_search",
|
||||
description: "Search the web for current information using DuckDuckGo.",
|
||||
properties: ["query": prop("string", "The search query to look up")],
|
||||
required: ["query"]
|
||||
)
|
||||
]
|
||||
let allowedNames = Set(readOnlyTools.map { $0.function.name })
|
||||
|
||||
var apiMessages: [[String: Any]] = [
|
||||
["role": "system", "content": "You are a read-only research sub-agent. Investigate the assigned task using the available tools and report concise findings as plain text. You cannot write, delete, or execute anything, and cannot spawn further sub-agents. Once you have enough information, respond with your final answer and stop calling tools."],
|
||||
["role": "user", "content": task]
|
||||
]
|
||||
|
||||
let maxIterations = 6
|
||||
for iteration in 0..<maxIterations {
|
||||
if Task.isCancelled { return "(cancelled)" }
|
||||
|
||||
guard let response = try? await provider.chatWithToolMessages(
|
||||
model: modelId, messages: apiMessages, tools: readOnlyTools, maxTokens: nil, temperature: nil
|
||||
) else {
|
||||
return "(error: sub-agent request failed)"
|
||||
}
|
||||
|
||||
let toolCalls = response.toolCalls ?? []
|
||||
guard !toolCalls.isEmpty else {
|
||||
return response.content.isEmpty ? "(no findings)" : response.content
|
||||
}
|
||||
|
||||
var assistantMsg: [String: Any] = ["role": "assistant"]
|
||||
if !response.content.isEmpty { assistantMsg["content"] = response.content }
|
||||
assistantMsg["tool_calls"] = toolCalls.map { tc in
|
||||
["id": tc.id, "type": tc.type, "function": ["name": tc.functionName, "arguments": tc.arguments]]
|
||||
}
|
||||
apiMessages.append(assistantMsg)
|
||||
|
||||
for tc in toolCalls {
|
||||
let resultJSON: String
|
||||
if allowedNames.contains(tc.functionName) {
|
||||
let result = await executeTool(name: tc.functionName, arguments: tc.arguments)
|
||||
resultJSON = serializeToolResult(result)
|
||||
} else {
|
||||
resultJSON = "{\"error\": \"Tool not available to research sub-agents\"}"
|
||||
}
|
||||
apiMessages.append([
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"name": tc.functionName,
|
||||
"content": resultJSON
|
||||
])
|
||||
}
|
||||
|
||||
if iteration == maxIterations - 1 {
|
||||
return "(research incomplete: sub-agent reached its iteration limit)"
|
||||
}
|
||||
}
|
||||
return "(no findings)"
|
||||
}
|
||||
|
||||
private func serializeToolResult(_ result: [String: Any], maxBytes: Int = 20_000) -> String {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: result),
|
||||
let str = String(data: data, encoding: .utf8) else {
|
||||
return "{\"error\": \"Failed to serialize result\"}"
|
||||
}
|
||||
guard str.utf8.count > maxBytes, let truncated = String(str.utf8.prefix(maxBytes)) else {
|
||||
return str
|
||||
}
|
||||
return truncated + "\n... (result truncated)"
|
||||
}
|
||||
|
||||
// MARK: - Personal Data (Calendar/Reminders) Approval
|
||||
|
||||
private func executePersonalDataAction(toolName: String, argumentsJSON: String, summary: String) async -> [String: Any] {
|
||||
guard settings.personalDataRequireApproval, !personalDataSessionApproved else {
|
||||
return await eventKitService.executeWriteTool(name: toolName, arguments: argumentsJSON)
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
self.pendingPersonalDataAction = PendingPersonalDataAction(toolName: toolName, argumentsJSON: argumentsJSON, summary: summary)
|
||||
self.pendingPersonalDataContinuation = continuation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func approvePendingPersonalDataAction(forSession: Bool = false) {
|
||||
guard let pending = pendingPersonalDataAction, let cont = pendingPersonalDataContinuation else { return }
|
||||
pendingPersonalDataAction = nil
|
||||
pendingPersonalDataContinuation = nil
|
||||
if forSession {
|
||||
personalDataSessionApproved = true
|
||||
}
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let result = await self.eventKitService.executeWriteTool(name: pending.toolName, arguments: pending.argumentsJSON)
|
||||
cont.resume(returning: result)
|
||||
}
|
||||
}
|
||||
|
||||
func denyPendingPersonalDataAction() {
|
||||
guard pendingPersonalDataAction != nil else { return }
|
||||
pendingPersonalDataAction = nil
|
||||
pendingPersonalDataContinuation?.resume(returning: ["error": "User denied this action"])
|
||||
pendingPersonalDataContinuation = nil
|
||||
}
|
||||
|
||||
func resetPersonalDataSessionApproval() {
|
||||
personalDataSessionApproved = false
|
||||
}
|
||||
|
||||
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
|
||||
let timeoutSeconds = settings.bashTimeout
|
||||
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
@@ -27,6 +27,18 @@ import Foundation
|
||||
import os
|
||||
import Security
|
||||
|
||||
/// Kill switch for the Personal Data tools (Calendar/Reminders/Contacts/Location & Maps).
|
||||
/// Flip `isHiddenPendingAppleFix` back to `false` once Apple fixes the macOS 27 beta TCC bug
|
||||
/// (filed with Apple). Each flag hides the relevant UI and forces the `*Enabled` getter to
|
||||
/// return `false` regardless of the persisted DB value — no code deleted, just inert.
|
||||
enum PersonalDataTools {
|
||||
/// Hides the entire Personal Data section. Flip to `false` once all four services work.
|
||||
static let isHiddenPendingAppleFix = false
|
||||
/// Hides only the Contacts row. Contacts TCC still broken under hardened runtime on
|
||||
/// macOS 27 beta 2 while Calendar/Reminders/Location are fixed. Flip to `false` once fixed.
|
||||
static let isContactsHiddenPendingAppleFix = true
|
||||
}
|
||||
|
||||
@Observable
|
||||
class SettingsService {
|
||||
static let shared = SettingsService()
|
||||
@@ -43,6 +55,7 @@ class SettingsService {
|
||||
static let googleSearchEngineID = "googleSearchEngineID"
|
||||
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
|
||||
static let paperlessAPIToken = "paperlessAPIToken"
|
||||
static let jarvisAPIKey = "jarvisAPIKey"
|
||||
}
|
||||
|
||||
// Old keychain keys (for migration only)
|
||||
@@ -299,6 +312,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
|
||||
|
||||
var mcpCanWriteFiles: Bool {
|
||||
@@ -500,6 +531,46 @@ class SettingsService {
|
||||
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
|
||||
|
||||
var bashEnabled: Bool {
|
||||
@@ -518,6 +589,37 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Research Agents Settings
|
||||
|
||||
/// When true, the AI can call `spawn_research_agents` to run multiple read-only
|
||||
/// sub-agents in parallel. Each sub-agent is its own full chain of model calls, so
|
||||
/// this can noticeably increase cost — opt-in, off by default.
|
||||
var agentsEnabled: Bool {
|
||||
get { cache["agentsEnabled"] == "true" }
|
||||
set {
|
||||
cache["agentsEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "agentsEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var maxConcurrentAgents: Int {
|
||||
get { cache["maxConcurrentAgents"].flatMap(Int.init) ?? 3 }
|
||||
set {
|
||||
cache["maxConcurrentAgents"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "maxConcurrentAgents", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// When true (and an active "2nd Brain" Agent Skill is installed), bash commands that
|
||||
/// invoke the 2nd Brain helper script skip the approval dialog entirely.
|
||||
var trustSecondBrainSkill: Bool {
|
||||
get { cache["trustSecondBrainSkill"] == "true" }
|
||||
set {
|
||||
cache["trustSecondBrainSkill"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "trustSecondBrainSkill", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var bashWorkingDirectory: String {
|
||||
get { cache["bashWorkingDirectory"] ?? "~" }
|
||||
set {
|
||||
@@ -534,6 +636,48 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Personal Data Settings (Calendar/Reminders/Contacts/Location/Maps)
|
||||
|
||||
var calendarEnabled: Bool {
|
||||
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["calendarEnabled"] == "true" }
|
||||
set {
|
||||
cache["calendarEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "calendarEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var remindersEnabled: Bool {
|
||||
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["remindersEnabled"] == "true" }
|
||||
set {
|
||||
cache["remindersEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "remindersEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var contactsEnabled: Bool {
|
||||
get { !PersonalDataTools.isHiddenPendingAppleFix && !PersonalDataTools.isContactsHiddenPendingAppleFix && cache["contactsEnabled"] == "true" }
|
||||
set {
|
||||
cache["contactsEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "contactsEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var locationMapsEnabled: Bool {
|
||||
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["locationMapsEnabled"] == "true" }
|
||||
set {
|
||||
cache["locationMapsEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "locationMapsEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var personalDataRequireApproval: Bool {
|
||||
get { cache["personalDataRequireApproval"].map { $0 == "true" } ?? true }
|
||||
set {
|
||||
cache["personalDataRequireApproval"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "personalDataRequireApproval", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paperless-NGX Settings
|
||||
|
||||
var paperlessEnabled: Bool {
|
||||
|
||||
@@ -35,6 +35,11 @@ final class UpdateCheckService {
|
||||
|
||||
var updateAvailable: Bool = false
|
||||
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 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 {
|
||||
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 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) {
|
||||
await MainActor.run {
|
||||
self.latestVersion = latestVer
|
||||
self.downloadURL = dmgURL
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable {
|
||||
// MARK: - File Logger
|
||||
|
||||
final class FileLogger: @unchecked Sendable {
|
||||
static let shared = FileLogger()
|
||||
nonisolated static let shared = FileLogger()
|
||||
|
||||
private let fileHandle: FileHandle?
|
||||
private let queue = DispatchQueue(label: "com.oai.filelogger")
|
||||
@@ -70,8 +70,8 @@ final class FileLogger: @unchecked Sendable {
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Current minimum log level (read from UserDefaults for thread safety)
|
||||
var minimumLevel: LogLevel {
|
||||
/// Current minimum log level (backed by UserDefaults — thread-safe).
|
||||
nonisolated var minimumLevel: LogLevel {
|
||||
get {
|
||||
let raw = UserDefaults.standard.integer(forKey: "logLevel")
|
||||
return LogLevel(rawValue: raw) ?? .info
|
||||
@@ -95,7 +95,7 @@ final class FileLogger: @unchecked Sendable {
|
||||
fileHandle?.seekToEndOfFile()
|
||||
}
|
||||
|
||||
func write(_ level: LogLevel, category: String, message: String) {
|
||||
nonisolated func write(_ level: LogLevel, category: String, message: String) {
|
||||
guard level >= minimumLevel else { return }
|
||||
queue.async { [weak self] in
|
||||
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)
|
||||
|
||||
struct AppLogger {
|
||||
let osLogger: Logger
|
||||
// os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are
|
||||
// nonisolated — FileLogger runs on its own serial queue, os.Logger dispatches to main actor.
|
||||
struct AppLogger: Sendable {
|
||||
let subsystem: String
|
||||
let category: String
|
||||
|
||||
func debug(_ message: String) {
|
||||
nonisolated func debug(_ message: String) {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
osLogger.error("\(message, privacy: .public)")
|
||||
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log Namespace
|
||||
|
||||
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")
|
||||
static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database")
|
||||
static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp")
|
||||
static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings")
|
||||
static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search")
|
||||
static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui")
|
||||
static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general")
|
||||
nonisolated static let api = AppLogger(subsystem: subsystem, category: "api")
|
||||
nonisolated static let db = AppLogger(subsystem: subsystem, category: "database")
|
||||
nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp")
|
||||
nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings")
|
||||
nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
|
||||
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
|
||||
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class ChatViewModel {
|
||||
var showHistory: Bool = false
|
||||
var showShortcuts: Bool = false
|
||||
var showSkills: Bool = false
|
||||
var showJarvis: Bool = false
|
||||
var modelInfoTarget: ModelInfo? = nil
|
||||
var commandHistory: [String] = []
|
||||
var historyIndex: Int = 0
|
||||
@@ -742,6 +743,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
case "/skills":
|
||||
showSkills = true
|
||||
|
||||
case "/jarvis":
|
||||
showJarvis = true
|
||||
|
||||
case "/mcp":
|
||||
handleMCPCommand(args: args)
|
||||
|
||||
@@ -795,8 +799,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let mcpActive = mcpEnabled || settings.mcpEnabled
|
||||
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
|
||||
let bashActive = settings.bashEnabled
|
||||
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
|
||||
let researchAgentsActive = settings.agentsEnabled
|
||||
let modelSupportTools = selectedModel?.capabilities.tools ?? false
|
||||
if modelSupportTools && (anytypeActive || bashActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||
generateAIResponseWithTools(provider: provider, modelId: modelId)
|
||||
return
|
||||
}
|
||||
@@ -930,10 +936,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
messages[index].tokens = usage.completionTokens
|
||||
if let model = selectedModel {
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing
|
||||
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
||||
: nil
|
||||
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
@@ -997,10 +1000,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
messages[index].tokens = usage.completionTokens
|
||||
if let model = selectedModel {
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing
|
||||
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
||||
: nil
|
||||
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
@@ -1309,7 +1309,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
// Append the complete system prompt (default + custom)
|
||||
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
|
||||
|
||||
var messagesToSend: [Message] = memoryEnabled
|
||||
let messagesToSend: [Message] = memoryEnabled
|
||||
? messages.filter { $0.role != .system }
|
||||
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
|
||||
|
||||
@@ -1356,6 +1356,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
var didContinueAfterImages = false // Only inject temp-file continuation once
|
||||
var totalUsage: ChatResponse.Usage?
|
||||
var hitIterationLimit = false // Track if we exited due to hitting the limit
|
||||
var finishedWithEmptyContent = false // Model stopped calling tools but said nothing
|
||||
|
||||
for iteration in 0..<maxIterations {
|
||||
if Task.isCancelled {
|
||||
@@ -1408,6 +1409,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
continue
|
||||
}
|
||||
}
|
||||
if finalContent.isEmpty {
|
||||
// Some models (observed with Qwen via OpenRouter) stop calling tools
|
||||
// after a long tool-call chain but return no summarizing text at all.
|
||||
// Surface a placeholder and nudge a follow-up turn instead of a silent blank bubble.
|
||||
finishedWithEmptyContent = true
|
||||
finalContent = "[No response from the model — retrying]"
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1454,7 +1462,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
break
|
||||
}
|
||||
|
||||
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
|
||||
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments, agentProvider: provider, agentModelId: effectiveModelId)
|
||||
let resultJSON: String
|
||||
if let data = try? JSONSerialization.data(withJSONObject: result),
|
||||
let str = String(data: data, encoding: .utf8) {
|
||||
@@ -1525,10 +1533,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
// Calculate cost
|
||||
if let usage = totalUsage, let model = selectedModel {
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing
|
||||
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
||||
: nil
|
||||
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
|
||||
messages[index].cost = cost
|
||||
}
|
||||
@@ -1542,8 +1547,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
isGenerating = false
|
||||
streamingTask = nil
|
||||
|
||||
// If we hit the iteration limit and weren't cancelled, start auto-continue
|
||||
if hitIterationLimit && !wasCancelled {
|
||||
// If we hit the iteration limit, or the model returned no text at all, nudge a follow-up turn
|
||||
if (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled {
|
||||
startAutoContinue()
|
||||
}
|
||||
|
||||
@@ -1655,7 +1660,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
|
||||
let delay = Double(1 << attempt) // 2s, 4s, 8s
|
||||
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))")
|
||||
}
|
||||
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
@@ -2176,6 +2181,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
|
||||
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
|
||||
guard let provider = providerRegistry.getProvider(for: currentProvider),
|
||||
|
||||
@@ -37,12 +37,11 @@ struct ChatView: View {
|
||||
HeaderView(
|
||||
provider: viewModel.currentProvider,
|
||||
model: viewModel.selectedModel,
|
||||
stats: viewModel.sessionStats,
|
||||
onlineMode: viewModel.onlineMode,
|
||||
mcpEnabled: viewModel.mcpEnabled,
|
||||
mcpStatus: viewModel.mcpStatus,
|
||||
onModelSelect: onModelSelect,
|
||||
onProviderChange: onProviderChange
|
||||
onProviderChange: onProviderChange,
|
||||
conversationName: viewModel.currentConversationName,
|
||||
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
||||
onQuickSave: viewModel.quickSave
|
||||
)
|
||||
|
||||
// Messages
|
||||
@@ -85,10 +84,13 @@ struct ChatView: View {
|
||||
InputBar(
|
||||
text: $viewModel.inputText,
|
||||
isGenerating: viewModel.isGenerating,
|
||||
mcpStatus: viewModel.mcpStatus,
|
||||
onlineMode: viewModel.onlineMode,
|
||||
onSend: viewModel.sendMessage,
|
||||
onCancel: viewModel.cancelGeneration
|
||||
onCancel: viewModel.cancelGeneration,
|
||||
onToggleOnline: {
|
||||
viewModel.onlineMode.toggle()
|
||||
SettingsService.shared.onlineMode = viewModel.onlineMode
|
||||
}
|
||||
)
|
||||
|
||||
// Footer
|
||||
@@ -96,7 +98,9 @@ struct ChatView: View {
|
||||
stats: viewModel.sessionStats,
|
||||
conversationName: viewModel.currentConversationName,
|
||||
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
||||
onQuickSave: viewModel.quickSave
|
||||
onQuickSave: viewModel.quickSave,
|
||||
onlineMode: viewModel.onlineMode,
|
||||
mcpEnabled: viewModel.mcpEnabled
|
||||
)
|
||||
}
|
||||
.background(Color.oaiBackground)
|
||||
@@ -106,6 +110,9 @@ struct ChatView: View {
|
||||
.sheet(isPresented: $viewModel.showSkills) {
|
||||
AgentSkillsView()
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showJarvis) {
|
||||
JarvisView()
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { MCPService.shared.pendingBashCommand },
|
||||
set: { _ in }
|
||||
@@ -116,6 +123,16 @@ struct ChatView: View {
|
||||
onDeny: { MCPService.shared.denyPendingBashCommand() }
|
||||
)
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { MCPService.shared.pendingPersonalDataAction },
|
||||
set: { _ in }
|
||||
)) { pending in
|
||||
PersonalDataApprovalSheet(
|
||||
pending: pending,
|
||||
onApprove: { forSession in MCPService.shared.approvePendingPersonalDataAction(forSession: forSession) },
|
||||
onDeny: { MCPService.shared.denyPendingPersonalDataAction() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ContentView.swift
|
||||
// oAI
|
||||
//
|
||||
// Root navigation container
|
||||
// Root navigation container — NavigationSplitView with collapsible sidebar
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
@@ -24,30 +24,34 @@
|
||||
|
||||
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import Darwin // uname, sysctlbyname
|
||||
#endif
|
||||
|
||||
struct ContentView: View {
|
||||
@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 {
|
||||
@Bindable var vm = chatViewModel
|
||||
NavigationStack {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
SidebarView()
|
||||
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
|
||||
} detail: {
|
||||
ChatView(
|
||||
onModelSelect: { chatViewModel.showModelSelector = true },
|
||||
onProviderChange: { newProvider in
|
||||
chatViewModel.changeProvider(newProvider)
|
||||
}
|
||||
)
|
||||
.navigationTitle("")
|
||||
.toolbar {
|
||||
#if os(macOS)
|
||||
macOSToolbar
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 860, minHeight: 560)
|
||||
#if os(macOS)
|
||||
.onAppear {
|
||||
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
||||
checkIntelWarning()
|
||||
}
|
||||
.onKeyPress(.return, phases: .down) { press in
|
||||
if press.modifiers.contains(.command) {
|
||||
@@ -65,7 +69,6 @@ struct ContentView: View {
|
||||
let oldModel = chatViewModel.selectedModel
|
||||
chatViewModel.selectModel(model)
|
||||
chatViewModel.showModelSelector = false
|
||||
// Trigger auto-save on model switch
|
||||
Task {
|
||||
await chatViewModel.onModelSwitch(from: oldModel, to: model)
|
||||
}
|
||||
@@ -113,125 +116,56 @@ struct ContentView: View {
|
||||
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)
|
||||
@ToolbarContentBuilder
|
||||
private var macOSToolbar: some ToolbarContent {
|
||||
let settings = SettingsService.shared
|
||||
let showLabels = settings.showToolbarLabels
|
||||
let iconSize = settings.toolbarIconSize
|
||||
private func checkIntelWarning() {
|
||||
guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
|
||||
guard isIntelNative || isRosetta else { return }
|
||||
showIntelWarning = true
|
||||
}
|
||||
|
||||
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 }) {
|
||||
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.keyboardShortcut("l", modifiers: .command)
|
||||
.help("Saved conversations (Cmd+L)")
|
||||
|
||||
Button(action: { chatViewModel.showHistory = true }) {
|
||||
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.keyboardShortcut("h", modifiers: .command)
|
||||
.help("Command history (Cmd+H)")
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { chatViewModel.showModelSelector = true }) {
|
||||
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.keyboardShortcut("m", modifiers: .command)
|
||||
.help("Select AI model (Cmd+M)")
|
||||
|
||||
Button(action: {
|
||||
if let model = chatViewModel.selectedModel {
|
||||
chatViewModel.modelInfoTarget = model
|
||||
}
|
||||
}) {
|
||||
ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.keyboardShortcut("i", modifiers: .command)
|
||||
.help("Model info (Cmd+I)")
|
||||
.disabled(chatViewModel.selectedModel == nil)
|
||||
|
||||
Button(action: { chatViewModel.showStats = true }) {
|
||||
ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.help("Session statistics")
|
||||
|
||||
Button(action: { chatViewModel.showCredits = true }) {
|
||||
ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.help("Check API credits")
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { chatViewModel.showSettings = true }) {
|
||||
ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
.help("Settings (Cmd+,)")
|
||||
|
||||
Button(action: { chatViewModel.showHelp = true }) {
|
||||
ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.keyboardShortcut("/", modifiers: .command)
|
||||
.help("Help & commands (Cmd+/)")
|
||||
private var isIntelNative: Bool {
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let machine = withUnsafeBytes(of: &systemInfo.machine) {
|
||||
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
|
||||
}
|
||||
return machine.contains("x86_64")
|
||||
}
|
||||
|
||||
private var isRosetta: Bool {
|
||||
var ret: Int32 = 0
|
||||
var size = MemoryLayout<Int32>.size
|
||||
sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
|
||||
return ret == 1
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -30,15 +30,22 @@ struct FooterView: View {
|
||||
let conversationName: String?
|
||||
let hasUnsavedChanges: Bool
|
||||
let onQuickSave: (() -> Void)?
|
||||
let onlineMode: Bool
|
||||
let mcpEnabled: Bool
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
init(stats: SessionStats,
|
||||
conversationName: String? = nil,
|
||||
hasUnsavedChanges: Bool = false,
|
||||
onQuickSave: (() -> Void)? = nil) {
|
||||
onQuickSave: (() -> Void)? = nil,
|
||||
onlineMode: Bool = false,
|
||||
mcpEnabled: Bool = false) {
|
||||
self.stats = stats
|
||||
self.conversationName = conversationName
|
||||
self.hasUnsavedChanges = hasUnsavedChanges
|
||||
self.onQuickSave = onQuickSave
|
||||
self.onlineMode = onlineMode
|
||||
self.mcpEnabled = mcpEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -71,26 +78,26 @@ struct FooterView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Save indicator (only when chat has messages)
|
||||
if stats.messageCount > 0 {
|
||||
SaveIndicator(
|
||||
conversationName: conversationName,
|
||||
hasUnsavedChanges: hasUnsavedChanges,
|
||||
onSave: onQuickSave
|
||||
)
|
||||
// Status pills — Online, MCP, Sync
|
||||
#if os(macOS)
|
||||
HStack(spacing: 6) {
|
||||
if onlineMode {
|
||||
StatusPill(icon: "globe", label: "Online", color: .green)
|
||||
}
|
||||
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)
|
||||
UpdateBadge()
|
||||
#endif
|
||||
|
||||
// Shortcuts hint
|
||||
#if os(macOS)
|
||||
Text("⌘N New • ⌘M Model • ⌘S Save")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
@@ -242,7 +249,6 @@ struct SyncStatusFooter: View {
|
||||
|
||||
struct UpdateBadge: View {
|
||||
private let updater = UpdateCheckService.shared
|
||||
private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
||||
|
||||
var body: some View {
|
||||
if updater.updateAvailable {
|
||||
@@ -258,10 +264,6 @@ struct UpdateBadge: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("A new version is available — click to open the releases page")
|
||||
} else {
|
||||
Text("v\(currentVersion)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+126
-157
@@ -2,7 +2,8 @@
|
||||
// HeaderView.swift
|
||||
// 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
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
@@ -28,150 +29,29 @@ import SwiftUI
|
||||
struct HeaderView: View {
|
||||
let provider: Settings.Provider
|
||||
let model: ModelInfo?
|
||||
let stats: SessionStats
|
||||
let onlineMode: Bool
|
||||
let mcpEnabled: Bool
|
||||
let mcpStatus: String?
|
||||
let onModelSelect: () -> 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 registry = ProviderRegistry.shared
|
||||
private let gitSync = GitSyncService.shared
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 20) {
|
||||
// Provider picker dropdown — only shows configured providers
|
||||
Menu {
|
||||
ForEach(registry.configuredProviders, id: \.self) { p in
|
||||
Button {
|
||||
onProviderChange(p)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: p.iconName)
|
||||
Text(p.displayName)
|
||||
if p == provider {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: provider.iconName)
|
||||
.font(.system(size: settings.guiTextSize - 2))
|
||||
Text(provider.displayName)
|
||||
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 8))
|
||||
.opacity(0.7)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.providerColor(provider))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.help("Switch provider")
|
||||
|
||||
// Model info (clickable → model selector)
|
||||
Button(action: onModelSelect) {
|
||||
if let model = model {
|
||||
HStack(spacing: 6) {
|
||||
Text(model.name)
|
||||
.font(.system(size: settings.guiTextSize, weight: .medium))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
|
||||
// Capability badges
|
||||
HStack(spacing: 3) {
|
||||
if model.capabilities.vision {
|
||||
Image(systemName: "eye")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
if model.capabilities.tools {
|
||||
Image(systemName: "wrench")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
if model.capabilities.online {
|
||||
Image(systemName: "globe")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
if model.capabilities.imageGeneration {
|
||||
Image(systemName: "paintbrush")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
Text("No model selected")
|
||||
.font(.system(size: settings.guiTextSize))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Select model")
|
||||
|
||||
if let model = model {
|
||||
let isFav = settings.favoriteModelIds.contains(model.id)
|
||||
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
||||
Image(systemName: isFav ? "star.fill" : "star")
|
||||
.font(.system(size: settings.guiTextSize - 3))
|
||||
.foregroundColor(isFav ? .yellow : .oaiSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
||||
ZStack {
|
||||
// Left: provider + model + star
|
||||
HStack(spacing: 12) {
|
||||
providerMenu
|
||||
modelButton
|
||||
starButton
|
||||
Spacer()
|
||||
}
|
||||
|
||||
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)
|
||||
// Center: conversation title (macOS document-title style)
|
||||
conversationTitle
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Rectangle()
|
||||
@@ -180,25 +60,123 @@ struct HeaderView: View {
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatItem: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
private let settings = SettingsService.shared
|
||||
// MARK: - Conversation title (center)
|
||||
|
||||
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)
|
||||
@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 {
|
||||
ForEach(registry.configuredProviders, id: \.self) { p in
|
||||
Button {
|
||||
onProviderChange(p)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: p.iconName)
|
||||
Text(p.displayName)
|
||||
if p == provider { Image(systemName: "checkmark") }
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: provider.iconName)
|
||||
.font(.system(size: settings.guiTextSize - 2))
|
||||
Text(provider.displayName)
|
||||
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 8))
|
||||
.opacity(0.7)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.providerColor(provider))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.help("Switch provider")
|
||||
}
|
||||
|
||||
private var modelButton: some View {
|
||||
Button(action: onModelSelect) {
|
||||
if let model = model {
|
||||
HStack(spacing: 6) {
|
||||
Text(model.name)
|
||||
.font(.system(size: settings.guiTextSize, weight: .medium))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
HStack(spacing: 3) {
|
||||
if model.capabilities.vision {
|
||||
Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||
}
|
||||
if model.capabilities.tools {
|
||||
Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||
}
|
||||
if model.capabilities.online {
|
||||
Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||
}
|
||||
if model.capabilities.imageGeneration {
|
||||
Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
Text("No model selected")
|
||||
.font(.system(size: settings.guiTextSize))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Select model")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var starButton: some View {
|
||||
if let model = model {
|
||||
let isFav = settings.favoriteModelIds.contains(model.id)
|
||||
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
||||
Image(systemName: isFav ? "star.fill" : "star")
|
||||
.font(.system(size: settings.guiTextSize - 3))
|
||||
.foregroundColor(isFav ? .yellow : .oaiSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status Pills (used by SidebarView)
|
||||
|
||||
struct StatusPill: View {
|
||||
let icon: String
|
||||
let label: LocalizedStringKey
|
||||
@@ -284,15 +262,6 @@ struct SyncStatusPill: View {
|
||||
HeaderView(
|
||||
provider: .openrouter,
|
||||
model: ModelInfo.mockModels.first,
|
||||
stats: SessionStats(
|
||||
totalInputTokens: 125,
|
||||
totalOutputTokens: 434,
|
||||
totalCost: 0.00111,
|
||||
messageCount: 4
|
||||
),
|
||||
onlineMode: true,
|
||||
mcpEnabled: true,
|
||||
mcpStatus: "MCP",
|
||||
onModelSelect: {},
|
||||
onProviderChange: { _ in }
|
||||
)
|
||||
|
||||
+128
-132
@@ -2,7 +2,7 @@
|
||||
// InputBar.swift
|
||||
// oAI
|
||||
//
|
||||
// Message input bar with status indicators
|
||||
// Message input bar with resizable height and online toggle
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
@@ -24,24 +24,35 @@
|
||||
|
||||
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct InputBar: View {
|
||||
@Binding var text: String
|
||||
let isGenerating: Bool
|
||||
let mcpStatus: String?
|
||||
let onlineMode: Bool
|
||||
let onSend: () -> Void
|
||||
let onCancel: () -> Void
|
||||
let onToggleOnline: () -> Void
|
||||
|
||||
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 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
|
||||
private static let immediateCommands: Set<String> = [
|
||||
"/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",
|
||||
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
||||
"/mcp write on", "/mcp write off",
|
||||
@@ -56,121 +67,108 @@ struct InputBar: View {
|
||||
CommandSuggestionsView(
|
||||
searchText: text,
|
||||
selectedIndex: selectedSuggestionIndex,
|
||||
onSelect: { command in
|
||||
selectCommand(command)
|
||||
}
|
||||
onSelect: selectCommand
|
||||
)
|
||||
.frame(width: 400)
|
||||
.frame(maxHeight: 200)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 96) // Align with input box (status badges + spacing)
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
|
||||
// Input area
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
// Status indicators
|
||||
HStack(spacing: 6) {
|
||||
if let mcp = mcpStatus {
|
||||
StatusBadge(text: mcp, color: .blue)
|
||||
}
|
||||
if onlineMode {
|
||||
StatusBadge(text: "🌐", color: .green)
|
||||
}
|
||||
}
|
||||
.frame(width: 80, alignment: .leading)
|
||||
// Drag-to-resize handle
|
||||
dragHandle
|
||||
|
||||
// Text input
|
||||
// Input row
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
// Text input with globe toggle in bottom-left corner
|
||||
ZStack(alignment: .topLeading) {
|
||||
// Placeholder
|
||||
if text.isEmpty {
|
||||
Text("Type a message or / for commands...")
|
||||
.font(.system(size: settings.inputTextSize))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.top, 10)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
TextEditor(text: $text)
|
||||
.font(.system(size: settings.inputTextSize))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 44, maxHeight: 120)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.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
|
||||
// Editor — fills the fixed-height box, bottom area reserved for globe
|
||||
NativeTextEditor(
|
||||
text: $text,
|
||||
font: .systemFont(ofSize: settings.inputTextSize),
|
||||
textColor: NSColor(Color.oaiPrimary),
|
||||
isFocused: isInputFocused,
|
||||
onReturn: {
|
||||
if showCommandDropdown {
|
||||
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
|
||||
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
|
||||
selectCommand(suggestions[selectedSuggestionIndex].command)
|
||||
return .handled
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Return (plain or with Cmd): send message
|
||||
if !text.isEmpty {
|
||||
onSend()
|
||||
return .handled
|
||||
if !text.isEmpty { onSend(); return true }
|
||||
return true
|
||||
},
|
||||
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 .handled
|
||||
return false
|
||||
},
|
||||
onDownArrow: {
|
||||
if showCommandDropdown {
|
||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||
if selectedSuggestionIndex < count - 1 {
|
||||
selectedSuggestionIndex += 1; return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
onFocusChange: { focused in isInputFocused = focused }
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onChange(of: text) {
|
||||
showCommandDropdown = text.hasPrefix("/")
|
||||
selectedSuggestionIndex = 0
|
||||
}
|
||||
.padding(.bottom, 30)
|
||||
|
||||
// Online / offline toggle — bottom-left of the text box
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Button(action: onToggleOnline) {
|
||||
Image(systemName: onlineMode ? "globe" : "network.slash")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(onlineMode ? Color.green : Color.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(onlineMode
|
||||
? "Online mode on — click to go offline"
|
||||
: "Offline — click to go online")
|
||||
Spacer()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.frame(height: inputHeight)
|
||||
.background(Color.oaiSurface)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
|
||||
)
|
||||
|
||||
// Action buttons
|
||||
|
||||
// Send / stop + attach buttons
|
||||
VStack(spacing: 8) {
|
||||
#if os(macOS)
|
||||
// File attach button
|
||||
Button(action: pickFile) {
|
||||
Image(systemName: "paperclip")
|
||||
.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) {
|
||||
showCommandDropdown = false
|
||||
if Self.immediateCommands.contains(command) {
|
||||
// Execute immediately
|
||||
text = command
|
||||
onSend()
|
||||
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
|
||||
if shortcut.needsInput {
|
||||
text = command + " "
|
||||
} else {
|
||||
text = command
|
||||
onSend()
|
||||
}
|
||||
text = shortcut.needsInput ? command + " " : command
|
||||
if !shortcut.needsInput { onSend() }
|
||||
} else {
|
||||
// Put in input for user to complete
|
||||
text = command + " "
|
||||
}
|
||||
}
|
||||
@@ -235,36 +259,14 @@ struct InputBar: View {
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.message = "Select files to attach"
|
||||
|
||||
guard panel.runModal() == .OK else { return }
|
||||
|
||||
let paths = panel.urls.map { $0.path }
|
||||
// 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
|
||||
}
|
||||
let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ")
|
||||
text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct StatusBadge: View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
// MARK: - Command suggestions
|
||||
|
||||
struct CommandSuggestionsView: View {
|
||||
let searchText: String
|
||||
@@ -279,6 +281,7 @@ struct CommandSuggestionsView: View {
|
||||
("/retry", "Retry last message"),
|
||||
("/shortcuts", "Manage your prompt shortcuts"),
|
||||
("/skills", "Manage your agent skills"),
|
||||
("/jarvis", "Open Jarvis agent manager"),
|
||||
("/memory on", "Enable conversation memory"),
|
||||
("/memory off", "Disable conversation memory"),
|
||||
("/online on", "Enable web search"),
|
||||
@@ -303,10 +306,9 @@ struct CommandSuggestionsView: View {
|
||||
]
|
||||
|
||||
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)"))
|
||||
}
|
||||
return builtInCommands + shortcuts
|
||||
} + builtInCommands
|
||||
}
|
||||
|
||||
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
|
||||
@@ -343,26 +345,20 @@ struct CommandSuggestionsView: View {
|
||||
.id(suggestion.command)
|
||||
|
||||
if index < suggestions.count - 1 {
|
||||
Divider()
|
||||
.background(Color.oaiBorder)
|
||||
Divider().background(Color.oaiBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedIndex) {
|
||||
if selectedIndex < suggestions.count {
|
||||
withAnimation {
|
||||
proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
|
||||
}
|
||||
withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.oaiSurface)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.oaiBorder, lineWidth: 1)
|
||||
)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,10 +368,10 @@ struct CommandSuggestionsView: View {
|
||||
InputBar(
|
||||
text: .constant(""),
|
||||
isGenerating: false,
|
||||
mcpStatus: "📁 Files",
|
||||
onlineMode: true,
|
||||
onSend: {},
|
||||
onCancel: {}
|
||||
onCancel: {},
|
||||
onToggleOnline: {}
|
||||
)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
@@ -113,7 +113,9 @@ struct AgentSkillsView: View {
|
||||
onToggle: { settings.toggleAgentSkill(id: skill.id) },
|
||||
onEdit: { editContext = SkillEditContext(skill: skill) },
|
||||
onExport: { exportOne(skill) },
|
||||
onDelete: { settings.deleteAgentSkill(id: skill.id) }
|
||||
onDelete: { settings.deleteAgentSkill(id: skill.id) },
|
||||
isTrusted: settings.trustSecondBrainSkill,
|
||||
onToggleTrust: { settings.trustSecondBrainSkill.toggle() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -349,6 +351,8 @@ private struct AgentSkillRow: View {
|
||||
let onEdit: () -> Void
|
||||
let onExport: () -> Void
|
||||
let onDelete: () -> Void
|
||||
var isTrusted: Bool = false
|
||||
var onToggleTrust: (() -> Void)? = nil
|
||||
|
||||
private var fileCount: Int {
|
||||
AgentSkillFilesService.shared.listFiles(for: skill.id).count
|
||||
@@ -378,6 +382,14 @@ private struct AgentSkillRow: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 2nd Brain: let the user mark bash calls to its helper script as always-trusted,
|
||||
// skipping the bash approval dialog. Only shown for this specific skill while active.
|
||||
if skill.isActive, skill.isSecondBrainSkill, let onToggleTrust {
|
||||
Toggle("Trust", isOn: Binding(get: { isTrusted }, set: { _ in onToggleTrust() }))
|
||||
.toggleStyle(.switch).controlSize(.small)
|
||||
.help("When on, bash commands that call the 2nd Brain helper script run without asking for approval each time.")
|
||||
}
|
||||
|
||||
// File count badge
|
||||
if fileCount > 0 {
|
||||
Label("^[\(fileCount) file](inflect: true)", systemImage: "doc")
|
||||
@@ -479,7 +491,9 @@ struct AgentSkillsTabContent: View {
|
||||
onToggle: { settings.toggleAgentSkill(id: skill.id) },
|
||||
onEdit: { editContext = SkillEditContext(skill: skill) },
|
||||
onExport: { exportOne(skill) },
|
||||
onDelete: { settings.deleteAgentSkill(id: skill.id) }
|
||||
onDelete: { settings.deleteAgentSkill(id: skill.id) },
|
||||
isTrusted: settings.trustSecondBrainSkill,
|
||||
onToggleTrust: { settings.trustSecondBrainSkill.toggle() }
|
||||
)
|
||||
if idx < settings.agentSkills.count - 1 { Divider() }
|
||||
}
|
||||
|
||||
@@ -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 isSearching = false
|
||||
@State private var selectedIndex: Int = 0
|
||||
@State private var showCombineSheet = false
|
||||
@FocusState private var searchFocused: Bool
|
||||
private let settings = SettingsService.shared
|
||||
var onLoad: ((Conversation) -> Void)?
|
||||
@@ -70,6 +71,18 @@ struct ConversationListView: View {
|
||||
}
|
||||
.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 {
|
||||
Button(role: .destructive) {
|
||||
deleteSelected()
|
||||
@@ -298,6 +311,16 @@ struct ConversationListView: View {
|
||||
searchFocused = true
|
||||
}
|
||||
.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() {
|
||||
|
||||
@@ -180,6 +180,14 @@ private let helpCategories: [CommandCategory] = [
|
||||
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: [
|
||||
CommandDetail(
|
||||
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
|
||||
@Bindable private var settings = SettingsService.shared
|
||||
@State private var isDescriptionExpanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -78,8 +79,18 @@ struct ModelInfoView: View {
|
||||
Text(desc)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isDescriptionExpanded ? nil : 4)
|
||||
.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)
|
||||
}
|
||||
@@ -145,6 +156,32 @@ struct ModelInfoView: View {
|
||||
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)
|
||||
if let arch = model.architecture {
|
||||
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 {
|
||||
ModelInfoView(model: ModelInfo(
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
|
||||
@@ -38,11 +38,17 @@ struct ModelSelectorView: View {
|
||||
@State private var filterImageGen = false
|
||||
@State private var filterThinking = false
|
||||
@State private var filterFavorites = false
|
||||
@State private var selectedCategory: ModelCategory? = nil
|
||||
@State private var showCategoryPicker = false
|
||||
@State private var keyboardIndex: Int = -1
|
||||
@State private var sortOrder: ModelSortOrder = .default
|
||||
@State private var selectedInfoModel: ModelInfo? = nil
|
||||
@Bindable private var settings = SettingsService.shared
|
||||
|
||||
private var categoriesWithModels: Set<ModelCategory> {
|
||||
Set(models.flatMap(\.categories))
|
||||
}
|
||||
|
||||
private var filteredModels: [ModelInfo] {
|
||||
let q = searchText.lowercased()
|
||||
let filtered = models.filter { model in
|
||||
@@ -57,8 +63,9 @@ struct ModelSelectorView: View {
|
||||
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
||||
let matchesThinking = !filterThinking || model.capabilities.thinking
|
||||
let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
|
||||
let matchesCategory = selectedCategory == nil || model.categories.contains(selectedCategory!)
|
||||
|
||||
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites
|
||||
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites && matchesCategory
|
||||
}
|
||||
|
||||
let favIds = settings.favoriteModelIds
|
||||
@@ -100,6 +107,40 @@ struct ModelSelectorView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Category picker (only shown when at least one category has models)
|
||||
if !categoriesWithModels.isEmpty {
|
||||
Button(action: { showCategoryPicker.toggle() }) {
|
||||
HStack(spacing: 4) {
|
||||
if let cat = selectedCategory {
|
||||
Circle().fill(cat.color).frame(width: 7, height: 7)
|
||||
Text(LocalizedStringKey(cat.rawValue))
|
||||
} else {
|
||||
Image(systemName: "tag")
|
||||
Text("Category")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(selectedCategory != nil ? selectedCategory!.color.opacity(0.18) : Color.gray.opacity(0.1))
|
||||
.foregroundColor(selectedCategory != nil ? selectedCategory!.color : .secondary)
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(selectedCategory != nil ? selectedCategory!.color.opacity(0.4) : Color.clear, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Filter by category")
|
||||
.popover(isPresented: $showCategoryPicker, arrowEdge: .bottom) {
|
||||
CategoryPickerPopover(
|
||||
categoriesWithModels: categoriesWithModels,
|
||||
selectedCategory: $selectedCategory,
|
||||
onSelect: { keyboardIndex = -1 }
|
||||
)
|
||||
}
|
||||
} // end if !categoriesWithModels.isEmpty
|
||||
|
||||
// Favorites filter star
|
||||
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
||||
Image(systemName: filterFavorites ? "star.fill" : "star")
|
||||
@@ -180,7 +221,7 @@ struct ModelSelectorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.frame(minWidth: 740, minHeight: 500)
|
||||
.navigationTitle("Select Model")
|
||||
#if os(macOS)
|
||||
.onKeyPress(.downArrow) {
|
||||
@@ -255,6 +296,7 @@ struct FilterToggle: View {
|
||||
HStack(spacing: 4) {
|
||||
Text(icon)
|
||||
Text(label)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
@@ -264,6 +306,68 @@ struct FilterToggle: View {
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.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())
|
||||
.onTapGesture { onSelect() }
|
||||
|
||||
// Right side: capabilities + info button
|
||||
// Right side: capabilities + category dots + info button
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
// Capability icons
|
||||
HStack(spacing: 4) {
|
||||
@@ -335,6 +439,18 @@ struct ModelRowView: View {
|
||||
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
|
||||
Button(action: onInfo) {
|
||||
Image(systemName: "info.circle")
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// PersonalDataApprovalSheet.swift
|
||||
// oAI
|
||||
//
|
||||
// Approval UI for AI-requested Calendar/Reminders write actions
|
||||
//
|
||||
// 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 PersonalDataApprovalSheet: View {
|
||||
let pending: MCPService.PendingPersonalDataAction
|
||||
let onApprove: (_ forSession: Bool) -> Void
|
||||
let onDeny: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Header
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "calendar.badge.exclamationmark")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Allow This Action?")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
Text("The AI wants to make a change to your calendar or reminders")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Action description
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("ACTION")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(pending.summary)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// Buttons
|
||||
HStack(spacing: 8) {
|
||||
Button("Deny") {
|
||||
onDeny()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Allow Once") {
|
||||
onApprove(false)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.orange)
|
||||
|
||||
Button("Allow for Session") {
|
||||
onApprove(true)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 480)
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,22 @@ struct SettingsView: View {
|
||||
@State private var isTestingAnytype = false
|
||||
@State private var anytypeTestResult: String?
|
||||
|
||||
// Jarvis state
|
||||
@State private var jarvisURL = ""
|
||||
@State private var jarvisAPIKey = ""
|
||||
@State private var showJarvisKey = false
|
||||
@State private var isTestingJarvis = false
|
||||
@State private var jarvisTestResult: String?
|
||||
|
||||
// Default model picker state
|
||||
@State private var showDefaultModelPicker = false
|
||||
|
||||
// Personal Data state (Calendar/Reminders/Contacts/Location/Maps)
|
||||
@State private var calendarAccessState = EventKitService.shared.calendarAccessState
|
||||
@State private var remindersAccessState = EventKitService.shared.reminderAccessState
|
||||
@State private var contactsAccessState = ContactsService.shared.accessState
|
||||
@State private var locationAccessState = LocationMapsService.shared.accessState
|
||||
|
||||
// Paperless-NGX state
|
||||
@State private var paperlessURL = ""
|
||||
@State private var paperlessToken = ""
|
||||
@@ -155,6 +168,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(9, icon: "icloud.and.arrow.up", label: "Backup")
|
||||
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
|
||||
tabButton(11, icon: "server.rack", label: "Jarvis")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
@@ -186,6 +200,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
backupTab
|
||||
case 10:
|
||||
anytypeTab
|
||||
case 11:
|
||||
jarvisTab
|
||||
default:
|
||||
generalTab
|
||||
}
|
||||
@@ -742,6 +758,163 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Research Agents
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.indigo)
|
||||
Text("Research Agents")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
}
|
||||
Text("Let the AI spawn read-only research sub-agents to investigate multiple things in parallel (read files, list/search directories, search the web — no writing, no bash). Intended for genuinely independent research tasks, not everyday questions.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Status")
|
||||
formSection {
|
||||
row("Enable Research Agents") {
|
||||
Toggle("", isOn: $settingsService.agentsEnabled)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.top, 1)
|
||||
Text("Cost warning: each sub-agent runs its own full chain of model calls. A single request that spawns several agents can cost several times a normal reply. The AI is instructed to only use this for genuinely parallel research, but model behavior can vary — leave this off unless you want that tradeoff.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
if settingsService.agentsEnabled {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Settings")
|
||||
formSection {
|
||||
row("Max Concurrent Agents") {
|
||||
HStack(spacing: 8) {
|
||||
Stepper("", value: $settingsService.maxConcurrentAgents, in: 1...5)
|
||||
.labelsHidden()
|
||||
Text("\(settingsService.maxConcurrentAgents)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Personal Data
|
||||
// isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug).
|
||||
// isContactsHiddenPendingAppleFix hides just the Contacts row (still broken in beta 2
|
||||
// under hardened runtime while Calendar/Reminders/Location are fixed). Flip each flag
|
||||
// to false once Apple ships a fix.
|
||||
if !PersonalDataTools.isHiddenPendingAppleFix {
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.crop.circle.badge.checkmark")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.teal)
|
||||
Text("Personal Data")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
if PersonalDataTools.isContactsHiddenPendingAppleFix {
|
||||
Text("β")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.help("Beta Feature. May change.")
|
||||
}
|
||||
}
|
||||
Text("Let the AI access your Calendar, Reminders, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
.onAppear {
|
||||
// Permission status can change outside the app (System Settings, or a prior
|
||||
// request elsewhere) — re-read it fresh every time this tab appears rather than
|
||||
// trusting the one-time @State initializer.
|
||||
calendarAccessState = EventKitService.shared.calendarAccessState
|
||||
remindersAccessState = EventKitService.shared.reminderAccessState
|
||||
contactsAccessState = ContactsService.shared.accessState
|
||||
locationAccessState = LocationMapsService.shared.accessState
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Services")
|
||||
formSection {
|
||||
personalDataRow(
|
||||
title: "Calendar",
|
||||
isEnabled: $settingsService.calendarEnabled,
|
||||
state: calendarAccessState,
|
||||
systemSettingsAnchor: "Privacy_Calendars",
|
||||
requestAccess: { calendarAccessState = await EventKitService.shared.requestCalendarAccess() ? .granted : EventKitService.shared.calendarAccessState }
|
||||
)
|
||||
rowDivider()
|
||||
personalDataRow(
|
||||
title: "Reminders",
|
||||
isEnabled: $settingsService.remindersEnabled,
|
||||
state: remindersAccessState,
|
||||
systemSettingsAnchor: "Privacy_Reminders",
|
||||
requestAccess: { remindersAccessState = await EventKitService.shared.requestReminderAccess() ? .granted : EventKitService.shared.reminderAccessState }
|
||||
)
|
||||
rowDivider()
|
||||
if !PersonalDataTools.isContactsHiddenPendingAppleFix {
|
||||
personalDataRow(
|
||||
title: "Contacts",
|
||||
isEnabled: $settingsService.contactsEnabled,
|
||||
state: contactsAccessState,
|
||||
systemSettingsAnchor: "Privacy_Contacts",
|
||||
requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState }
|
||||
)
|
||||
rowDivider()
|
||||
}
|
||||
personalDataRow(
|
||||
title: "Location & Maps",
|
||||
isEnabled: $settingsService.locationMapsEnabled,
|
||||
state: locationAccessState,
|
||||
systemSettingsAnchor: "Privacy_LocationServices",
|
||||
requestAccess: { locationAccessState = await LocationMapsService.shared.requestAccess() ? .granted : LocationMapsService.shared.accessState }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if settingsService.calendarEnabled || settingsService.remindersEnabled {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Write Actions")
|
||||
formSection {
|
||||
row("Require Approval for Changes") {
|
||||
Toggle("", isOn: $settingsService.personalDataRequireApproval)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Creating calendar events or reminders, and completing reminders, will ask for your approval first.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Appearance Tab
|
||||
@@ -2047,6 +2220,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 {
|
||||
isTestingAnytype = true
|
||||
anytypeTestResult = nil
|
||||
@@ -2265,7 +2539,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||
}
|
||||
.frame(minWidth: 60)
|
||||
.frame(minWidth: 55)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 4)
|
||||
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
||||
@@ -2286,6 +2560,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
case 7: return "Skills"
|
||||
case 8: return "Paperless"
|
||||
case 9: return "Backup"
|
||||
case 10: return "Anytype"
|
||||
case 11: return "Jarvis"
|
||||
default: return "Settings"
|
||||
}
|
||||
}
|
||||
@@ -2333,6 +2609,57 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func personalDataRow(title: LocalizedStringKey, isEnabled: Binding<Bool>, state: PersonalDataAccessState, systemSettingsAnchor: String, requestAccess: @escaping () async -> Void) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Text(title).font(.system(size: 14))
|
||||
Spacer()
|
||||
Toggle("", isOn: isEnabled)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
if isEnabled.wrappedValue {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: state == .granted ? "checkmark.circle.fill" : (state == .denied ? "exclamationmark.circle.fill" : "circle"))
|
||||
.foregroundStyle(state == .granted ? .green : (state == .denied ? .orange : .secondary))
|
||||
.font(.system(size: 12))
|
||||
Text(statusText(for: state))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if state == .notDetermined {
|
||||
Button("Request Access") {
|
||||
Task { await requestAccess() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
} else if state == .denied {
|
||||
Button("Open System Settings") {
|
||||
openPrivacySettings(anchor: systemSettingsAnchor)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func statusText(for state: PersonalDataAccessState) -> LocalizedStringKey {
|
||||
switch state {
|
||||
case .granted: return "Access granted"
|
||||
case .denied: return "Access denied — enable in System Settings"
|
||||
case .notDetermined: return "Access not granted"
|
||||
}
|
||||
}
|
||||
|
||||
private func openPrivacySettings(anchor: String) {
|
||||
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(anchor)") else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func abbreviatePath(_ path: String) -> String {
|
||||
let home = NSHomeDirectory()
|
||||
if path.hasPrefix(home) {
|
||||
|
||||
@@ -36,6 +36,54 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSCalendarsFullAccessUsageDescription" : {
|
||||
"comment" : "Privacy - Calendars Full Access Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSContactsUsageDescription" : {
|
||||
"comment" : "Privacy - Contacts Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSLocationWhenInUseUsageDescription" : {
|
||||
"comment" : "Privacy - Location When In Use Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSRemindersFullAccessUsageDescription" : {
|
||||
"comment" : "Privacy - Reminders Full Access Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
||||
@@ -8,5 +8,13 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<false/>
|
||||
<key>com.apple.security.personal-information.calendars</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.reminders</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.contacts</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -92,6 +92,8 @@ struct oAIApp: App {
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Open Chat…") { chatViewModel.showConversations = true }
|
||||
.keyboardShortcut("o", modifiers: .command)
|
||||
Button("Search Conversations") { chatViewModel.showConversations = true }
|
||||
.keyboardShortcut("l", modifiers: .command)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .saveItem) {
|
||||
@@ -113,10 +115,44 @@ struct oAIApp: App {
|
||||
.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 ─────────────────────────────────────────────────
|
||||
CommandGroup(replacing: .help) {
|
||||
Button("oAI Help") { openHelp() }
|
||||
.keyboardShortcut("?", modifiers: .command)
|
||||
Divider()
|
||||
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
|
||||
UpdateCheckService.shared.checkForUpdatesManually()
|
||||
}
|
||||
.disabled(UpdateCheckService.shared.isCheckingManually)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user