28 Commits

Author SHA1 Message Date
rune 52ab774785 Merge pull request '2.4.1' (#7) from 2.4.1 into main
Reviewed-on: #7
2026-07-14 11:01:35 +02:00
rune 5031dceff8 Merge remote-tracking branch 'origin/main' into 2.4.1
# Conflicts:
#	oAI.xcodeproj/project.pbxproj
2026-07-14 11:00:16 +02:00
rune 0cefef16e4 Change Command History shortcut from ⌘H to ⇧⌘H
⌘H is reserved by macOS for "Hide Application" and pre-empts app-level
menu bindings before they ever fire, so the Command History shortcut
never actually worked. Moved to ⇧⌘H and updated all references (in-app
help, macOS Help Book, README).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:46:53 +02:00
rune 8cba92d768 Document External MCP Servers, Personal Data Tools, and Research Agents in Help
Adds three new Help sections covering the 2.4.1 features: connecting
external stdio MCP servers, Calendar/Reminders/Location access with
its approval flow, and parallel read-only research sub-agents. Also
notes French as a supported language and that OpenRouter's dedicated
image models are merged into the model picker automatically. Cleans
up a stray misplaced HTML comment above the Anytype section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:33:50 +02:00
rune bfcdd0164c Fix assistant message text truncating with "…" instead of wrapping
Two contributing layout issues in the chat message bubble:

1. MessageRow's content VStack (icon + text HStack) had no
   .frame(maxWidth: .infinity), so it sized to its content's ideal
   width instead of the space actually available.

2. swift-markdown-ui renders paragraphs with mixed inline styling
   (bold/italic runs next to plain text) as concatenated Text(+)
   segments, which on macOS report their ideal unwrapped single-line
   size for height purposes instead of wrapping — truncating with "…"
   regardless of window width. Plain single-style paragraphs (a single
   Text) weren't affected, which is why some lines wrapped fine and
   others didn't.

Fixed by adding .frame(maxWidth: .infinity, alignment: .leading) to
the MessageRow content stack, and .fixedSize(horizontal: false,
vertical: true) to the markdown paragraph label so height is
recomputed for the width actually given instead of the ideal width.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 08:38:39 +02:00
rune 7119cd1d06 Silence placeholder/continuing messages in the tool-call auto-retry path
The tool loop's max-iterations and empty-response fallbacks were showing
placeholder assistant bubbles ("[Tool loop reached maximum iterations]",
"[No response from the model — retrying]") followed by a "↩ Continuing…"
system message before silently re-running. None of that added anything
for the user, so the auto-continue now happens without any visible
message when there's no real content to show; genuine partial content
is still displayed as before, and usage/cost tracking is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 08:05:07 +02:00
rune e6f965ff19 Add external MCP server support (stdio JSON-RPC)
Lets the AI connect to any external stdio MCP server (e.g. safaridriver
--mcp) configured in Settings, with tools auto-discovered and prefixed
by server slug. Includes crash detection with backoff restart (5s/15s/30s)
and a Settings UI to add/enable/disable/remove servers.

Fixes the temp-dir allowlist in MCPService.isPathAllowed to also match
/tmp and /private/tmp (not just NSTemporaryDirectory(), which resolves
to a different per-user Darwin temp dir) so the MCP file tools can
actually read files external servers and image generation write there.
Also switches the Add Server sheet's argument parsing to a quote-aware
tokenizer so args containing spaces survive intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 08:01:00 +02:00
rune f2949cea3b Add OpenRouter dedicated images API support
Fetches /api/v1/images/models in parallel with /models and merges results
into the model picker. Image-only models (e.g. Sourceful, Seedream, Flux
via this endpoint) were previously invisible since they don't appear in the
standard /models endpoint.

Models from the images API get usesImagesAPI=true and route through a new
generateImageAPIResponse() path in ChatViewModel that POSTs to /api/v1/images
with {model, prompt} instead of the chat completions endpoint. The response's
b64_json data is decoded and displayed via the existing GeneratedImagesView.

Cost is taken directly from the usage.cost field in the images API response
(USD per image) via a new rawCostUSD field on ChatResponse.Usage, bypassing
the token-based calculateCost() path used for chat models.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:34:56 +02:00
rune 40c5f25517 Add personal data tools, 2nd Brain trust toggle, and research agents
Personal Data Tools: native Calendar, Reminders, Contacts (hidden pending
Apple TCC fix in beta), and Location & Maps access via EventKit, Contacts
framework, and MapKit. Write actions (create event/reminder, complete
reminder) gate through an approval sheet. Four hardened-runtime entitlements
added to oAI.entitlements; Info.plist usage strings added for all services.
Personal Data section shows a β badge while Contacts is hidden.

2nd Brain always-trust: inline toggle on the Agent Skills row for the skill
named "2nd Brain" skips the bash approval dialog when the command contains
.brain_helper.py, gated by three runtime checks in MCPService.

Research agents: spawn_research_agents tool runs up to 5 concurrent read-only
sub-agents (read_file, list_directory, search_files, web_search — no write,
no bash, no nesting). Bounded by maxConcurrentAgents setting (default 3) and
a hard ceiling of 8 tasks. Added items field to Tool.Function.Parameters.Property
for JSON Schema array support; wired into AnthropicProvider.convertParametersToDict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:04:47 +02:00
rune 454cef4193 Update AppLogo imageset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:04:27 +02:00
rune 66c9054bd5 Update README with i18n disclosure and newly added features
Document Jarvis integration, Combine Conversations, model category
filter, sidebar navigation, prompt caching, and the 2nd Brain trust
toggle. Clarify that localization is AI/machine-translated rather than
reviewed by native speakers. Normalize all em-dashes to plain hyphens.
2026-06-22 11:14:34 +02:00
rune 56099c079c Fix accidental macOS 27 deployment target bump
MACOSX_DEPLOYMENT_TARGET was silently bumped 26.2 -> 27.0 in commit
8451db1, most likely by Xcode beta auto-updating it when the project
was opened/built with the macOS 27 beta SDK. This shipped in the public
v2.4 release, meaning the app refused to launch on anything older than
macOS 27 beta. No code in the project actually requires macOS 27 APIs.
2026-06-22 11:06:29 +02:00
rune 20121981a0 Add French localization and catch up nb/sv/da/de translations
French (fr) added as a 5th supported language; full catalog translated.
Also caught up nb/sv/da/de for ~300 strings added since the last
localization pass (Jarvis, Anytype, model categories, reasoning effort,
Combine Conversations) plus Button/Toggle/Menu/CommandMenu titles and
custom sectionHeader/row helpers in Settings that were never extracted
by prior tooling, leaving Settings and the View menu English-only
regardless of locale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:17:36 +02:00
rune e8db4ad7a3 Merge pull request '2.4' (#6) from 2.4 into main
Reviewed-on: #6
2026-06-19 08:05:36 +02:00
rune 5b99a6f81c Add Anthropic prompt caching (direct + via OpenRouter)
Caches the system prompt/tools and growing conversation history via
cache_control breakpoints, cutting cost and latency on repeated turns.
Covers both the regular chat path and the tool-calling loop
(chatWithToolMessages), which has its own request-building code and was
initially missed. Cost calculation now accounts for cache write/read
pricing instead of treating all input tokens as full price. Verified
live: cache reads grow turn-over-turn in oAI.log.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:18:48 +02:00
54 changed files with 16601 additions and 714 deletions
+1
View File
@@ -4,6 +4,7 @@
## User settings ## User settings
xcuserdata/ xcuserdata/
xcshareddata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint *.xcscmblueprint
-11
View File
@@ -72,17 +72,6 @@ oAI/
## Building ## Building
### Build Scripts
| Script | Architecture | Output |
|--------|-------------|--------|
| `build.sh` | Apple Silicon (arm64) | Installs directly to `/Applications` |
| `build-dmg.sh` | Apple Silicon (arm64) | `oAI-<version>-AppleSilicon.dmg` on Desktop |
| `build-dmg-universal.sh` | Universal (arm64 + x86_64) | `oAI-<version>-Universal.dmg` on Desktop |
| `build_nb/sv/da/de/en.sh` | Apple Silicon (arm64) | Build + launch in specific language |
All scripts: find Developer ID cert, clean-build via `xcodebuild`, re-sign with `codesign --options runtime --timestamp`, verify. Version is read from `MARKETING_VERSION` in `project.pbxproj`.
### Manual Build Commands ### Manual Build Commands
```bash ```bash
+37 -29
View File
@@ -8,19 +8,20 @@ A powerful native macOS AI chat application with support for multiple providers,
### 🤖 Multi-Provider Support ### 🤖 Multi-Provider Support
- **OpenAI** - GPT models with native API support - **OpenAI** - GPT models with native API support
- **Anthropic** - All Claude models - **Anthropic** - All Claude models; prompt caching support (direct API and via OpenRouter) reduces cost on repeated system prompts/context
- **OpenRouter** - Access to 300+ AI models from multiple providers - **OpenRouter** - Access to 300+ AI models from multiple providers
- **Ollama** - Local model inference for privacy - **Ollama** - Local model inference for privacy
### 💬 Core Chat Capabilities ### 💬 Core Chat Capabilities
- **Streaming Responses** - Real-time token streaming for faster interactions - **Streaming Responses** - Real-time token streaming for faster interactions
- **Conversation Management** - Save, load, export, and search conversations - **Conversation Management** - Save, load, export, and search conversations
- **Combine Conversations** - Merge 2+ saved conversations, either by chronological concatenation or AI-assisted synthesis
- **File Attachments** - Support for text files, images, and PDFs - **File Attachments** - Support for text files, images, and PDFs
- **Image Generation** - Create images with supported models (DALL-E, Flux, etc.) renders inline in chat - **Image Generation** - Create images with supported models (DALL-E, Flux, etc.) - renders inline in chat
- **Reasoning / Thinking Tokens** - Stream live reasoning from thinking-capable models (DeepSeek R1, Claude 3.7+, o1/o3, Qwen); configurable effort level (High/Medium/Low/Minimal); collapsible block auto-expands while thinking and collapses when the answer arrives - **Reasoning / Thinking Tokens** - Stream live reasoning from thinking-capable models (DeepSeek R1, Claude 3.7+, o1/o3, Qwen); configurable effort level (High/Medium/Low/Minimal); collapsible block auto-expands while thinking and collapses when the answer arrives
- **Online Mode** - DuckDuckGo and Google web search integration - **Online Mode** - DuckDuckGo and Google web search integration
- **Session Statistics** - Track token usage, costs, and response times - **Session Statistics** - Track token usage, costs, and response times
- **Command History** - Navigate previous commands with searchable modal (⌘H) - **Command History** - Navigate previous commands with searchable modal (⌘H)
### 🧠 Enhanced Memory & Context System ### 🧠 Enhanced Memory & Context System
- **Smart Context Selection** - Automatically select relevant messages to reduce token usage by 50-80% - **Smart Context Selection** - Automatically select relevant messages to reduce token usage by 50-80%
@@ -51,17 +52,25 @@ Seamless conversation backup and sync across devices:
### ⚡ Shortcuts & Agent Skills ### ⚡ Shortcuts & Agent Skills
- **Shortcuts** - Personal slash commands that expand to prompt templates; optional `{{input}}` placeholder for inline input - **Shortcuts** - Personal slash commands that expand to prompt templates; optional `{{input}}` placeholder for inline input
- **Agent Skills (SKILL.md)** - Markdown instruction files injected into the system prompt; compatible with skill0.io, skillsmp.com, and other SKILL.md marketplaces; import as `.md` or `.zip` bundle with attached data files - **Agent Skills (SKILL.md)** - Markdown instruction files injected into the system prompt; compatible with skill0.io, skillsmp.com, and other SKILL.md marketplaces; import as `.md` or `.zip` bundle with attached data files; a skill named exactly "2nd Brain" can be marked always-trusted, skipping the bash approval prompt for its helper-script calls
### 📚 Anytype Integration ### 📚 Anytype Integration
Connect oAI to your local [Anytype](https://anytype.io) knowledge base: Connect oAI to your local [Anytype](https://anytype.io) knowledge base:
- **Search** find objects by keyword across all spaces or within a specific one - **Search** - find objects by keyword across all spaces or within a specific one
- **Read** open any object and read its full markdown content - **Read** - open any object and read its full markdown content
- **Append** add content to the end of an existing object without touching existing text or internal links (preferred over full update) - **Append** - add content to the end of an existing object without touching existing text or internal links (preferred over full update)
- **Create** make new notes, tasks, or pages - **Create** - make new notes, tasks, or pages
- **Checkbox tools** surgically toggle to-do checkboxes or set task done/undone via native relation - **Checkbox tools** - surgically toggle to-do checkboxes or set task done/undone via native relation
- All data stays on your machine (local API, no cloud) - All data stays on your machine (local API, no cloud)
### 🛰️ Jarvis Integration
Connect oAI to a self-hosted [Jarvis](https://jarvis.pm) agent-automation server:
- **Agent Management** - List, create, edit, enable/disable, run, and stop agents
- **Run History** - Expandable per-run output with status and timing
- **Usage & Credits** - Per-agent usage stats and credits balance
- **Queue Control** - Pause/resume all agents
- `/jarvis` slash command opens the Jarvis panel directly
### 🖥️ Power-User Features ### 🖥️ Power-User Features
- **Bash Execution** - AI can run shell commands via `/bin/zsh` (opt-in, with per-command approval prompt) - **Bash Execution** - AI can run shell commands via `/bin/zsh` (opt-in, with per-command approval prompt)
- **iCloud Backup** - One-click settings backup to iCloud Drive; restore on any Mac; API keys excluded for security - **iCloud Backup** - One-click settings backup to iCloud Drive; restore on any Mac; API keys excluded for security
@@ -85,9 +94,10 @@ Automated email responses powered by AI:
- Footer stats display (messages, tokens, cost, sync status) - Footer stats display (messages, tokens, cost, sync status)
- Header status indicators (MCP, Online mode, Git sync) - Header status indicators (MCP, Online mode, Git sync)
- Responsive message layout with copy buttons - Responsive message layout with copy buttons
- **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠), sort by price or context window, search by name or description, per-row ⓘ info button; ★ favourite any model favourites float to the top and can be filtered in one click - **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠) or by category (Programming, Math, Medical, Translation, Roleplay, Creative, Science, Finance, Legal), sort by price or context window, search by name or description, per-row ⓘ info button; ★ favourite any model - favourites float to the top and can be filtered in one click
- **Default Model** - Set a fixed startup model in Settings → General; switching models during a session does not overwrite it - **Default Model** - Set a fixed startup model in Settings → General; switching models during a session does not overwrite it
- **Localization** - UI ~~fully translated~~ being translated into Norwegian Bokmål, Swedish, Danish, and German; follows macOS language preference automatically - **Sidebar Navigation** - Collapsible sidebar for switching between conversations
- **Localization** - Fully localized into Norwegian Bokmål, Swedish, Danish, German, and French; follows macOS language preference automatically. Translations are AI-generated (machine translation), not reviewed by native speakers - if you spot an awkward or incorrect phrase, please [open an issue](https://gitlab.pm/rune/oai-swift/issues/new)
![Advanced Features](Screenshots/4.png) ![Advanced Features](Screenshots/4.png)
@@ -97,8 +107,8 @@ Automated email responses powered by AI:
Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-swift/releases). Two builds are available: Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-swift/releases). Two builds are available:
- **oAI-x.x.x-AppleSilicon.dmg** for Macs with an Apple Silicon chip (M1 and later) - **oAI-x.x.x-AppleSilicon.dmg** - for Macs with an Apple Silicon chip (M1 and later)
- **oAI-x.x.x-Universal.dmg** runs natively on both Apple Silicon and Intel Macs - **oAI-x.x.x-Universal.dmg** - runs natively on both Apple Silicon and Intel Macs
### Installing from DMG ### Installing from DMG
@@ -107,19 +117,19 @@ Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-
3. Eject the DMG 3. Eject the DMG
4. Launch oAI from Applications or Spotlight 4. Launch oAI from Applications or Spotlight
### First Launch Gatekeeper Warning ### First Launch - Gatekeeper Warning
oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified." oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan - the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified."
To open the app, you have two options: To open the app, you have two options:
**Option A Right-click to open (quickest):** **Option A - Right-click to open (quickest):**
1. Right-click (or Control-click) `oAI.app` in Applications 1. Right-click (or Control-click) `oAI.app` in Applications
2. Select **Open** from the context menu 2. Select **Open** from the context menu
3. Click **Open** in the dialog that appears 3. Click **Open** in the dialog that appears
4. After doing this once, the app opens normally from then on 4. After doing this once, the app opens normally from then on
**Option B Remove the quarantine flag via Terminal:** **Option B - Remove the quarantine flag via Terminal:**
```bash ```bash
xattr -dr com.apple.quarantine /Applications/oAI.app xattr -dr com.apple.quarantine /Applications/oAI.app
@@ -139,7 +149,7 @@ Add your API keys in Settings (⌘,) → General tab:
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth - **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys) - **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
- **Ollama** - Base URL (default: http://localhost:11434) - **Ollama** - Base URL (default: http://localhost:11434)
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) not a chat provider - **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) - not a chat provider
### Essential Settings ### Essential Settings
@@ -183,7 +193,7 @@ Add your API keys in Settings (⌘,) → General tab:
- `/load` or `/list` - List and load saved conversations (⌘L) - `/load` or `/list` - List and load saved conversations (⌘L)
- `/delete <name>` - Delete a saved conversation - `/delete <name>` - Delete a saved conversation
- `/export <md|json> [filename]` - Export conversation - `/export <md|json> [filename]` - Export conversation
- `/history` - Open command history modal (⌘H) - `/history` - Open command history modal (⌘H)
### Provider & Settings ### Provider & Settings
- `/provider [name]` - Switch or display current provider - `/provider [name]` - Switch or display current provider
@@ -228,7 +238,7 @@ Can you review this code? @~/project/main.swift
- `⌘,` - Open settings - `⌘,` - Open settings
- `⌘N` - New conversation - `⌘N` - New conversation
- `⌘L` - List saved conversations - `⌘L` - List saved conversations
- `⌘H` - Command history - `⌘H` - Command history
- `Esc` - Cancel generation / Close dropdown - `Esc` - Cancel generation / Close dropdown
- `↑/↓` - Navigate command dropdown (when typing `/`) - `↑/↓` - Navigate command dropdown (when typing `/`)
- `Return` - Send message - `Return` - Send message
@@ -313,12 +323,16 @@ AI-powered email auto-responder:
- [x] Vector index for faster semantic search (sqlite-vss) - [x] Vector index for faster semantic search (sqlite-vss)
- [x] Reasoning / thinking tokens (streamed live, collapsible) - [x] Reasoning / thinking tokens (streamed live, collapsible)
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German) - [x] Localization (Norwegian Bokmål, Swedish, Danish, German, French)
- [x] iCloud Backup (settings export/restore) - [x] iCloud Backup (settings export/restore)
- [x] Bash execution with per-command approval - [x] Bash execution with per-command approval
- [x] Anytype integration (read, append, create, checkbox tools) - [x] Anytype integration (read, append, create, checkbox tools)
- [x] Model favourites (starred models, filter, float to top) - [x] Model favourites (starred models, filter, float to top)
- [ ] SOUL.md / USER.md — living identity documents injected into system prompt - [x] Jarvis integration (agent management, run history, usage/credits)
- [x] Model category filter (Programming, Math, Medical, etc.)
- [x] Combine saved conversations (concatenation or AI-assisted synthesis)
- [x] Sidebar navigation redesign
- [ ] SOUL.md / USER.md - living identity documents injected into system prompt
- [ ] Parallel research agents (read-only, concurrent) - [ ] Parallel research agents (read-only, concurrent)
- [ ] Local embeddings (sentence-transformers, $0 cost) - [ ] Local embeddings (sentence-transformers, $0 cost)
- [ ] Multi-modal conversation export (PDF, HTML) - [ ] Multi-modal conversation export (PDF, HTML)
@@ -332,9 +346,6 @@ This means you are free to use, study, modify, and distribute oAI, but any modif
See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html). See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html).
## Development
See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, database schema, and contribution guidelines.
## Author ## Author
@@ -344,15 +355,12 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, datab
- Blog: [https://blog.rune.pm](https://blog.rune.pm) - Blog: [https://blog.rune.pm](https://blog.rune.pm)
- Gitlab.pm: [@rune](https://gitlab.pm/rune) - Gitlab.pm: [@rune](https://gitlab.pm/rune)
## Contributing
Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instructions and project structure.
--- ---
## Disclaimer ## Disclaimer
oAI takes real actions on your behalf it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms. oAI takes real actions on your behalf - it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind - the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
--- ---
+15 -4
View File
@@ -104,6 +104,7 @@
da, da,
de, de,
sv, sv,
fr,
); );
mainGroup = A550A6592F3B72EA00136F2B; mainGroup = A550A6592F3B72EA00136F2B;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
@@ -269,6 +270,11 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; 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=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -279,11 +285,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.2; IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.3.9; MARKETING_VERSION = 2.4.1;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -313,6 +319,11 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; 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=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -323,11 +334,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.2; IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.3.9; MARKETING_VERSION = 2.4.1;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
BuildableName = "oAI.app"
BlueprintName = "oAI"
ReferencedContainer = "container:oAI.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "nb"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
BuildableName = "oAI.app"
BlueprintName = "oAI"
ReferencedContainer = "container:oAI.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
BuildableName = "oAI.app"
BlueprintName = "oAI"
ReferencedContainer = "container:oAI.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xCA",
"green" : "0x7A",
"red" : "0x0A"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+8
View File
@@ -4,6 +4,14 @@
"filename" : "AppLogo.png", "filename" : "AppLogo.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {
+8
View File
@@ -6,5 +6,13 @@
<string>oAI.help</string> <string>oAI.help</string>
<key>CFBundleHelpBookName</key> <key>CFBundleHelpBookName</key>
<string>oAI Help</string> <string>oAI Help</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>nb</string>
<string>da</string>
<string>de</string>
<string>sv</string>
</array>
</dict> </dict>
</plist> </plist>
File diff suppressed because it is too large Load Diff
+6
View File
@@ -57,4 +57,10 @@ struct AgentSkill: Codable, Identifiable {
} }
return name 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
}
} }
+1 -1
View File
@@ -33,7 +33,7 @@ struct Conversation: Identifiable, Codable {
var updatedAt: Date var updatedAt: Date
var primaryModel: String? // Primary model used in this conversation var primaryModel: String? // Primary model used in this conversation
init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
name: String, name: String,
messages: [Message] = [], messages: [Message] = [],
+1 -1
View File
@@ -44,7 +44,7 @@ struct EmailLog: Identifiable, Codable, Equatable {
let responseTime: TimeInterval? // Time to generate response in seconds let responseTime: TimeInterval? // Time to generate response in seconds
let modelId: String? // Model that handled the email let modelId: String? // Model that handled the email
init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
timestamp: Date = Date(), timestamp: Date = Date(),
sender: String, sender: String,
+1 -1
View File
@@ -66,7 +66,7 @@ struct Message: Identifiable, Codable, Equatable {
// Reasoning/thinking content (not persisted in-memory only) // Reasoning/thinking content (not persisted in-memory only)
var thinkingContent: String? = nil var thinkingContent: String? = nil
init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
role: MessageRole, role: MessageRole,
content: String, content: String,
+1
View File
@@ -47,6 +47,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
let online: Bool // Web search let online: Bool // Web search
var imageGeneration: Bool = false // Image output var imageGeneration: Bool = false // Image output
var thinking: Bool = false // Reasoning/thinking tokens var thinking: Bool = false // Reasoning/thinking tokens
var usesImagesAPI: Bool = false // OpenRouter dedicated /images endpoint
} }
struct Architecture: Codable, Hashable { struct Architecture: Codable, Hashable {
+39
View File
@@ -130,11 +130,37 @@ struct ChatResponse: Codable {
let promptTokens: Int let promptTokens: Int
let completionTokens: Int let completionTokens: Int
let totalTokens: Int let totalTokens: Int
let cacheCreationInputTokens: Int?
let cacheReadInputTokens: Int?
/// Direct USD cost returned by the images API (bypasses token-based calculation).
let rawCostUSD: Double?
init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil, rawCostUSD: Double? = nil) {
self.promptTokens = promptTokens
self.completionTokens = completionTokens
self.totalTokens = totalTokens
self.cacheCreationInputTokens = cacheCreationInputTokens
self.cacheReadInputTokens = cacheReadInputTokens
self.rawCostUSD = rawCostUSD
}
// rawCostUSD is set programmatically, never decoded from API responses
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens" case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens" case completionTokens = "completion_tokens"
case totalTokens = "total_tokens" case totalTokens = "total_tokens"
case cacheCreationInputTokens = "cache_creation_input_tokens"
case cacheReadInputTokens = "cache_read_input_tokens"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
promptTokens = try c.decode(Int.self, forKey: .promptTokens)
completionTokens = try c.decode(Int.self, forKey: .completionTokens)
totalTokens = try c.decode(Int.self, forKey: .totalTokens)
cacheCreationInputTokens = try c.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens)
cacheReadInputTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens)
rawCostUSD = nil
} }
} }
@@ -217,6 +243,19 @@ struct Tool: Codable {
let type: String let type: String
let description: String let description: String
let `enum`: [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
}
} }
} }
} }
+68 -4
View File
@@ -77,6 +77,15 @@ class AnthropicProvider: AIProvider {
/// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301") /// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301")
/// still inherit the correct pricing tier. /// still inherit the correct pricing tier.
private static let knownModels: [ModelInfo] = [ private static let knownModels: [ModelInfo] = [
// Claude Fable 5
ModelInfo(
id: "claude-fable-5",
name: "Claude Fable 5",
description: "Anthropic's creative and storytelling model",
contextLength: 200_000,
pricing: .init(prompt: 10.0, completion: 50.0),
capabilities: .init(vision: true, tools: true, online: true)
),
// Claude 4.x series // Claude 4.x series
ModelInfo( ModelInfo(
id: "claude-opus-4-6", id: "claude-opus-4-6",
@@ -173,6 +182,7 @@ class AnthropicProvider: AIProvider {
/// Pricing tiers used for fuzzy fallback matching on unknown model IDs. /// Pricing tiers used for fuzzy fallback matching on unknown model IDs.
/// Keyed by model name prefix (longest match wins). /// Keyed by model name prefix (longest match wins).
private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [ private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [
("claude-fable", 10.0, 50.0),
("claude-opus", 15.0, 75.0), ("claude-opus", 15.0, 75.0),
("claude-sonnet", 3.0, 15.0), ("claude-sonnet", 3.0, 15.0),
("claude-haiku", 0.80, 4.0), ("claude-haiku", 0.80, 4.0),
@@ -356,6 +366,19 @@ class AnthropicProvider: AIProvider {
} }
} }
// Mark the last message with a cache breakpoint so the next loop
// iteration (or next turn) can reuse everything up through this one.
if var lastMessage = conversationMessages.popLast() {
if let content = lastMessage["content"] as? String {
lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]]
} else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() {
lastBlock["cache_control"] = ["type": "ephemeral"]
blocks.append(lastBlock)
lastMessage["content"] = blocks
}
conversationMessages.append(lastMessage)
}
var body: [String: Any] = [ var body: [String: Any] = [
"model": model, "model": model,
"messages": conversationMessages, "messages": conversationMessages,
@@ -363,7 +386,9 @@ class AnthropicProvider: AIProvider {
"stream": false "stream": false
] ]
if let systemText = systemText { if let systemText = systemText {
body["system"] = systemText // Array form carries a cache breakpoint; also covers tools, which
// render before system in Anthropic's prefix order.
body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]]
} }
if let temperature = temperature { if let temperature = temperature {
body["temperature"] = temperature body["temperature"] = temperature
@@ -430,6 +455,8 @@ class AnthropicProvider: AIProvider {
var currentId = "" var currentId = ""
var currentModel = request.model var currentModel = request.model
var inputTokens = 0 var inputTokens = 0
var cacheCreationTokens: Int? = nil
var cacheReadTokens: Int? = nil
for try await line in bytes.lines { for try await line in bytes.lines {
// Anthropic SSE: "event: ..." and "data: {...}" // Anthropic SSE: "event: ..." and "data: {...}"
@@ -449,6 +476,11 @@ class AnthropicProvider: AIProvider {
currentModel = message["model"] as? String ?? request.model currentModel = message["model"] as? String ?? request.model
if let usageDict = message["usage"] as? [String: Any] { if let usageDict = message["usage"] as? [String: Any] {
inputTokens = usageDict["input_tokens"] as? Int ?? 0 inputTokens = usageDict["input_tokens"] as? Int ?? 0
cacheCreationTokens = usageDict["cache_creation_input_tokens"] as? Int
cacheReadTokens = usageDict["cache_read_input_tokens"] as? Int
if cacheCreationTokens != nil || cacheReadTokens != nil {
Log.api.info("Anthropic stream cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)")
}
} }
} }
@@ -472,7 +504,13 @@ class AnthropicProvider: AIProvider {
var usage: ChatResponse.Usage? = nil var usage: ChatResponse.Usage? = nil
if let usageDict = event["usage"] as? [String: Any] { if let usageDict = event["usage"] as? [String: Any] {
let outputTokens = usageDict["output_tokens"] as? Int ?? 0 let outputTokens = usageDict["output_tokens"] as? Int ?? 0
usage = ChatResponse.Usage(promptTokens: inputTokens, completionTokens: outputTokens, totalTokens: inputTokens + outputTokens) usage = ChatResponse.Usage(
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens,
cacheCreationInputTokens: cacheCreationTokens,
cacheReadInputTokens: cacheReadTokens
)
} }
continuation.yield(StreamChunk( continuation.yield(StreamChunk(
id: currentId, id: currentId,
@@ -582,6 +620,19 @@ class AnthropicProvider: AIProvider {
} }
} }
// Mark the last message with a cache breakpoint so the next turn can
// reuse everything up through this one as a cached prefix.
if var lastMessage = apiMessages.popLast() {
if let content = lastMessage["content"] as? String {
lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]]
} else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() {
lastBlock["cache_control"] = ["type": "ephemeral"]
blocks.append(lastBlock)
lastMessage["content"] = blocks
}
apiMessages.append(lastMessage)
}
var body: [String: Any] = [ var body: [String: Any] = [
"model": request.model, "model": request.model,
"messages": apiMessages, "messages": apiMessages,
@@ -590,7 +641,10 @@ class AnthropicProvider: AIProvider {
] ]
if let systemText = systemText { if let systemText = systemText {
body["system"] = systemText // Array form (rather than a plain string) carries a cache breakpoint.
// Per Anthropic's render order (tools -> system -> messages), this
// single breakpoint caches the tool definitions too.
body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]]
} }
if let temperature = request.temperature { if let temperature = request.temperature {
body["temperature"] = temperature body["temperature"] = temperature
@@ -665,6 +719,11 @@ class AnthropicProvider: AIProvider {
let usageDict = json["usage"] as? [String: Any] let usageDict = json["usage"] as? [String: Any]
let inputTokens = usageDict?["input_tokens"] as? Int ?? 0 let inputTokens = usageDict?["input_tokens"] as? Int ?? 0
let outputTokens = usageDict?["output_tokens"] as? Int ?? 0 let outputTokens = usageDict?["output_tokens"] as? Int ?? 0
let cacheCreationTokens = usageDict?["cache_creation_input_tokens"] as? Int
let cacheReadTokens = usageDict?["cache_read_input_tokens"] as? Int
if cacheCreationTokens != nil || cacheReadTokens != nil {
Log.api.info("Anthropic cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)")
}
return ChatResponse( return ChatResponse(
id: id, id: id,
@@ -675,7 +734,9 @@ class AnthropicProvider: AIProvider {
usage: ChatResponse.Usage( usage: ChatResponse.Usage(
promptTokens: inputTokens, promptTokens: inputTokens,
completionTokens: outputTokens, completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens totalTokens: inputTokens + outputTokens,
cacheCreationInputTokens: cacheCreationTokens,
cacheReadInputTokens: cacheReadTokens
), ),
created: Date(), created: Date(),
toolCalls: toolCalls.isEmpty ? nil : toolCalls toolCalls: toolCalls.isEmpty ? nil : toolCalls
@@ -692,6 +753,9 @@ class AnthropicProvider: AIProvider {
if let enumVals = prop.enum { if let enumVals = prop.enum {
propDict["enum"] = enumVals propDict["enum"] = enumVals
} }
if let items = prop.items {
propDict["items"] = ["type": items.type]
}
props[key] = propDict props[key] = propDict
} }
var dict: [String: Any] = [ var dict: [String: Any] = [
+79
View File
@@ -48,6 +48,11 @@ struct OpenRouterChatRequest: Codable {
let toolChoice: String? let toolChoice: String?
let modalities: [String]? let modalities: [String]?
let reasoning: ReasoningAPIConfig? let reasoning: ReasoningAPIConfig?
let cacheControl: CacheControl?
struct CacheControl: Codable {
let type: String
}
struct APIMessage: Codable { struct APIMessage: Codable {
let role: String let role: String
@@ -138,6 +143,7 @@ struct OpenRouterChatRequest: Codable {
case toolChoice = "tool_choice" case toolChoice = "tool_choice"
case modalities case modalities
case reasoning case reasoning
case cacheControl = "cache_control"
} }
} }
@@ -225,11 +231,23 @@ struct OpenRouterChatResponse: Codable {
let promptTokens: Int let promptTokens: Int
let completionTokens: Int let completionTokens: Int
let totalTokens: Int let totalTokens: Int
let promptTokensDetails: PromptTokensDetails?
struct PromptTokensDetails: Codable {
let cachedTokens: Int?
let cacheWriteTokens: Int?
enum CodingKeys: String, CodingKey {
case cachedTokens = "cached_tokens"
case cacheWriteTokens = "cache_write_tokens"
}
}
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens" case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens" case completionTokens = "completion_tokens"
case totalTokens = "total_tokens" case totalTokens = "total_tokens"
case promptTokensDetails = "prompt_tokens_details"
} }
} }
} }
@@ -402,6 +420,67 @@ struct ToolResultMessage: Encodable {
} }
} }
// MARK: - Images API Model Discovery
struct OpenRouterImageModelsResponse: Codable {
let data: [ImageModelData]
struct ImageModelData: Codable {
let id: String
let name: String
let description: String?
let architecture: Architecture?
let supportsStreaming: Bool?
struct Architecture: Codable {
let inputModalities: [String]?
let outputModalities: [String]?
enum CodingKeys: String, CodingKey {
case inputModalities = "input_modalities"
case outputModalities = "output_modalities"
}
}
enum CodingKeys: String, CodingKey {
case id, name, description, architecture
case supportsStreaming = "supports_streaming"
}
}
}
// MARK: - Images API Generation Response
struct OpenRouterImageGenerationResponse: Codable {
let created: Int?
let data: [ImageData]
let usage: Usage?
struct ImageData: Codable {
let b64Json: String
let mediaType: String?
enum CodingKeys: String, CodingKey {
case b64Json = "b64_json"
case mediaType = "media_type"
}
}
struct Usage: Codable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
let cost: Double?
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
case cost
}
}
}
// MARK: - Error Response // MARK: - Error Response
struct OpenRouterErrorResponse: Codable { struct OpenRouterErrorResponse: Codable {
+138 -24
View File
@@ -53,30 +53,16 @@ class OpenRouterProvider: AIProvider {
func listModels() async throws -> [ModelInfo] { func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from OpenRouter") Log.api.info("Fetching model list from OpenRouter")
let url = URL(string: "\(baseURL)/models")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request) // Fetch chat models and image models in parallel
async let chatData = fetchRaw(path: "/models")
async let imageData = fetchRaw(path: "/images/models")
let (chatRaw, imageRaw) = try await (chatData, imageData)
guard let httpResponse = response as? HTTPURLResponse else { let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: chatRaw)
Log.api.error("OpenRouter models: invalid response (not HTTP)") Log.api.info("OpenRouter loaded \(modelsResponse.data.count) chat models")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else { var models = modelsResponse.data.map { modelData in
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data)
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) models")
return modelsResponse.data.map { modelData in
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0 let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
let completionPrice = Double(modelData.pricing.completion) ?? 0.0 let completionPrice = Double(modelData.pricing.completion) ?? 0.0
@@ -129,6 +115,108 @@ class OpenRouterProvider: AIProvider {
) )
return info return info
} }
// Merge dedicated image models (these don't appear in /models)
if let imageModelsResponse = try? JSONDecoder().decode(OpenRouterImageModelsResponse.self, from: imageRaw) {
Log.api.info("OpenRouter loaded \(imageModelsResponse.data.count) image models")
let existingIds = Set(models.map { $0.id })
let imageModels = imageModelsResponse.data.compactMap { m -> ModelInfo? in
guard !existingIds.contains(m.id) else { return nil }
let acceptsImageInput = m.architecture?.inputModalities?.contains("image") ?? false
var info = ModelInfo(
id: m.id,
name: m.name,
description: m.description,
contextLength: 0,
pricing: ModelInfo.Pricing(prompt: 0, completion: 0),
capabilities: ModelInfo.ModelCapabilities(
vision: acceptsImageInput,
tools: false,
online: false,
imageGeneration: true,
thinking: false,
usesImagesAPI: true
),
topProvider: m.id.components(separatedBy: "/").first
)
info.categories = ModelCategory.infer(name: m.name, id: m.id, description: m.description)
return info
}
models.append(contentsOf: imageModels)
}
return models
}
private func fetchRaw(path: String) async throws -> Data {
let url = URL(string: "\(baseURL)\(path)")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { throw ProviderError.invalidResponse }
guard httpResponse.statusCode == 200 else {
if let err = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
throw ProviderError.unknown(err.error.message)
}
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return data
}
// MARK: - Images API
func generateImage(model: String, prompt: String) async throws -> ChatResponse {
Log.api.info("OpenRouter images API: model=\(model)")
let url = URL(string: "\(baseURL)/images")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: ["model": model, "prompt": prompt])
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else { throw ProviderError.invalidResponse }
if httpResponse.statusCode != 200 {
if let err = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter images HTTP \(httpResponse.statusCode): \(err.error.message)")
throw ProviderError.unknown(err.error.message)
}
Log.api.error("OpenRouter images HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
if let rawStr = String(data: data, encoding: .utf8) {
Log.api.debug("Images API raw response (first 200 chars): \(rawStr.prefix(200))")
}
let imageResponse = try JSONDecoder().decode(OpenRouterImageGenerationResponse.self, from: data)
let images: [Data] = imageResponse.data.compactMap { item in
Data(base64Encoded: item.b64Json)
}
let usage: ChatResponse.Usage? = imageResponse.usage.map { u in
ChatResponse.Usage(
promptTokens: u.promptTokens,
completionTokens: u.completionTokens,
totalTokens: u.totalTokens,
rawCostUSD: u.cost
)
}
return ChatResponse(
id: UUID().uuidString,
model: model,
content: "",
role: "assistant",
finishReason: "stop",
usage: usage,
created: Date(),
generatedImages: images.isEmpty ? nil : images
)
} }
func getModel(_ id: String) async throws -> ModelInfo? { func getModel(_ id: String) async throws -> ModelInfo? {
@@ -198,6 +286,11 @@ class OpenRouterProvider: AIProvider {
} }
if let maxTokens = maxTokens { body["max_tokens"] = maxTokens } if let maxTokens = maxTokens { body["max_tokens"] = maxTokens }
if let temperature = temperature { body["temperature"] = temperature } if let temperature = temperature { body["temperature"] = temperature }
// Anthropic models require an explicit cache_control opt-in on OpenRouter;
// other providers cache automatically.
if model.hasPrefix("anthropic/") {
body["cache_control"] = ["type": "ephemeral"]
}
var urlRequest = URLRequest(url: url) var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST" urlRequest.httpMethod = "POST"
@@ -388,6 +481,12 @@ class OpenRouterProvider: AIProvider {
ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil) ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil)
} }
// Anthropic models require an explicit cache_control opt-in on OpenRouter;
// other providers (OpenAI, DeepSeek, Gemini, Grok, etc.) cache automatically.
let cacheControl: OpenRouterChatRequest.CacheControl? = effectiveModel.hasPrefix("anthropic/")
? .init(type: "ephemeral")
: nil
return OpenRouterChatRequest( return OpenRouterChatRequest(
model: effectiveModel, model: effectiveModel,
messages: apiMessages, messages: apiMessages,
@@ -398,7 +497,8 @@ class OpenRouterProvider: AIProvider {
tools: request.tools, tools: request.tools,
toolChoice: request.tools != nil ? "auto" : nil, toolChoice: request.tools != nil ? "auto" : nil,
modalities: request.imageGeneration ? ["text", "image"] : nil, modalities: request.imageGeneration ? ["text", "image"] : nil,
reasoning: reasoningConfig reasoning: reasoningConfig,
cacheControl: cacheControl
) )
} }
@@ -416,6 +516,11 @@ class OpenRouterProvider: AIProvider {
let allImages = topLevelImages + blockImages let allImages = topLevelImages + blockImages
let images: [Data]? = allImages.isEmpty ? nil : allImages let images: [Data]? = allImages.isEmpty ? nil : allImages
if let details = apiResponse.usage?.promptTokensDetails,
details.cachedTokens != nil || details.cacheWriteTokens != nil {
Log.api.info("OpenRouter cache usage: model=\(apiResponse.model), created=\(details.cacheWriteTokens ?? 0), read=\(details.cachedTokens ?? 0)")
}
return ChatResponse( return ChatResponse(
id: apiResponse.id, id: apiResponse.id,
model: apiResponse.model, model: apiResponse.model,
@@ -426,7 +531,9 @@ class OpenRouterProvider: AIProvider {
ChatResponse.Usage( ChatResponse.Usage(
promptTokens: usage.promptTokens, promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens, completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens totalTokens: usage.totalTokens,
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
) )
}, },
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)), created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
@@ -446,6 +553,11 @@ class OpenRouterProvider: AIProvider {
let allImages = topLevelImages + blockImages let allImages = topLevelImages + blockImages
let images: [Data]? = allImages.isEmpty ? nil : allImages let images: [Data]? = allImages.isEmpty ? nil : allImages
if let details = apiChunk.usage?.promptTokensDetails,
details.cachedTokens != nil || details.cacheWriteTokens != nil {
Log.api.info("OpenRouter stream cache usage: model=\(apiChunk.model), created=\(details.cacheWriteTokens ?? 0), read=\(details.cachedTokens ?? 0)")
}
return StreamChunk( return StreamChunk(
id: apiChunk.id, id: apiChunk.id,
model: apiChunk.model, model: apiChunk.model,
@@ -460,7 +572,9 @@ class OpenRouterProvider: AIProvider {
ChatResponse.Usage( ChatResponse.Usage(
promptTokens: usage.promptTokens, promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens, completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens totalTokens: usage.totalTokens,
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
) )
} }
) )
@@ -36,6 +36,9 @@
<li><a href="#shortcuts">Shortcuts (Prompt Templates)</a></li> <li><a href="#shortcuts">Shortcuts (Prompt Templates)</a></li>
<li><a href="#agent-skills">Agent Skills (SKILL.md)</a></li> <li><a href="#agent-skills">Agent Skills (SKILL.md)</a></li>
<li><a href="#anytype">Anytype Integration</a></li> <li><a href="#anytype">Anytype Integration</a></li>
<li><a href="#external-mcp">External MCP Servers</a></li>
<li><a href="#personal-data">Personal Data Tools</a></li>
<li><a href="#research-agents">Research Agents</a></li>
<li><a href="#bash-execution">Bash Execution</a></li> <li><a href="#bash-execution">Bash Execution</a></li>
<li><a href="#icloud-backup">iCloud Backup</a></li> <li><a href="#icloud-backup">iCloud Backup</a></li>
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li> <li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
@@ -49,7 +52,7 @@
<!-- Getting Started --> <!-- Getting Started -->
<section id="getting-started"> <section id="getting-started">
<h2>Getting Started</h2> <h2>Getting Started</h2>
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama. The app is available in English, Norwegian Bokmål, Swedish, Danish, and German — it follows your macOS language preference automatically.</p> <p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama. The app is available in English, Norwegian Bokmål, Swedish, Danish, German, and French — it follows your macOS language preference automatically.</p>
<div class="steps"> <div class="steps">
<h3>Quick Start</h3> <h3>Quick Start</h3>
@@ -115,6 +118,10 @@
<li><strong>🧠 Thinking</strong> — models that support reasoning / thinking tokens</li> <li><strong>🧠 Thinking</strong> — models that support reasoning / thinking tokens</li>
</ul> </ul>
<div class="note">
<strong>Note:</strong> On OpenRouter, dedicated image-generation models (e.g. Sourceful, Seedream, Flux) are fetched from OpenRouter's separate images catalog and merged into the picker automatically — you don't need to configure anything extra to see them.
</div>
<h3>Sorting</h3> <h3>Sorting</h3>
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p> <p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
<ul> <ul>
@@ -1370,7 +1377,6 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
</div> </div>
</section> </section>
<!-- Keyboard Shortcuts -->
<!-- Anytype Integration --> <!-- Anytype Integration -->
<section id="anytype"> <section id="anytype">
<h2>Anytype Integration</h2> <h2>Anytype Integration</h2>
@@ -1413,6 +1419,104 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
</ul> </ul>
</section> </section>
<!-- External MCP Servers -->
<section id="external-mcp">
<h2>External MCP Servers</h2>
<p>Connect any external MCP server that speaks the stdio JSON-RPC protocol (for example <code>safaridriver --mcp</code>) and give the AI access to its tools — no custom integration code needed.</p>
<h3>Adding a Server</h3>
<ol>
<li>Press <kbd>⌘,</kbd> to open Settings</li>
<li>Go to the <strong>MCP</strong> tab → <strong>External MCP Servers</strong> section</li>
<li>Click <strong>Add Server…</strong></li>
<li>Enter a <strong>Name</strong> (used to prefix its tools, e.g. "Safari" → <code>safari_navigate_to_url</code>), the <strong>Command</strong> to launch it, and any <strong>Arguments</strong></li>
<li>Click <strong>Add</strong> — the server starts automatically and its tools are discovered</li>
</ol>
<div class="tip">
<strong>💡 Tip:</strong> Arguments containing spaces can be quoted, e.g. <code>--root "/Users/you/My Documents"</code>.
</div>
<h3>Server Status</h3>
<p>Each configured server shows a status dot and label:</p>
<ul>
<li><strong>🟢 Connected</strong> — running and its tools are available to the AI</li>
<li><strong>🟠 Connecting…</strong> — starting up or performing the initial handshake</li>
<li><strong>🔴 Error / Crashed</strong> — failed to start or exited unexpectedly</li>
<li><strong>⚪ Not started</strong> — disabled via the toggle</li>
</ul>
<p>Toggle a server off/on or delete it entirely with the trash icon. Crashed servers automatically restart up to 3 times with increasing delay (5s, 15s, 30s) before giving up.</p>
<div class="note">
<strong>Note:</strong> Tool names from every external server are prefixed with that server's slug (derived from its Name) so they never collide with oAI's built-in tools or each other.
</div>
</section>
<!-- Personal Data Tools -->
<section id="personal-data">
<h2>Personal Data Tools <span style="font-size: 0.75em; background: #f90; color: #fff; border-radius: 4px; padding: 1px 5px; vertical-align: middle;">Beta</span></h2>
<p>Let the AI access your Calendar, Reminders, and Location &amp; Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses Apple's own frameworks (EventKit, CoreLocation, MapKit) with standard macOS permission prompts — nothing goes through a third-party service.</p>
<h3>Enabling a Service</h3>
<ol>
<li>Press <kbd>⌘,</kbd> to open Settings</li>
<li>Go to the <strong>MCP</strong> tab → <strong>Personal Data</strong> section</li>
<li>Toggle on the services you want: <strong>Calendar</strong>, <strong>Reminders</strong>, or <strong>Location &amp; Maps</strong></li>
<li>Click <strong>Request Access</strong> next to a service — macOS shows its standard permission prompt</li>
</ol>
<h3>What the AI Can Do</h3>
<ul>
<li><strong>Calendar</strong> — list your calendars and upcoming events, create new events</li>
<li><strong>Reminders</strong> — list reminder lists and items, create new reminders, mark reminders complete</li>
<li><strong>Location &amp; Maps</strong> — get your current location, search for places, geocode addresses, and get directions (all read-only)</li>
</ul>
<div class="warning">
<strong>⚠️ Write actions require approval:</strong> Creating a calendar event or reminder, or completing a reminder, shows an approval sheet first with a plain-language summary of what will happen. Choose <strong>Deny</strong>, <strong>Allow Once</strong>, or <strong>Allow for Session</strong>. Toggle this requirement off in Settings → MCP → Personal Data → Require Approval for Changes.
</div>
<div class="note">
<strong>Note:</strong> Contacts support exists internally but is currently hidden while a macOS permission bug affecting hardened-runtime apps is worked out on Apple's side.
</div>
<h3>Example Prompts</h3>
<ul>
<li>"What's on my calendar tomorrow?"</li>
<li>"Remind me to call the dentist on Friday at 2pm"</li>
<li>"How far is the nearest coffee shop from here?"</li>
</ul>
</section>
<!-- Research Agents -->
<section id="research-agents">
<h2>Research Agents</h2>
<p>For tasks that involve searching or reading many files, the AI can spawn several read-only research sub-agents that work in parallel instead of doing everything itself, one step at a time.</p>
<div class="warning">
<strong>⚠️ Cost warning:</strong> 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. Leave this off unless you specifically want that tradeoff.
</div>
<h3>Enabling Research Agents</h3>
<ol>
<li>Press <kbd>⌘,</kbd> to open Settings</li>
<li>Go to the <strong>MCP</strong> tab → <strong>Research Agents</strong> section</li>
<li>Toggle <strong>Enable Research Agents</strong> on</li>
<li>Adjust <strong>Max Concurrent Agents</strong> (15, default 3) to control how many sub-agents can run at once</li>
</ol>
<h3>What Sub-Agents Can Do</h3>
<p>Sub-agents are intentionally limited to read-only investigation — they cannot write files, run shell commands, or spawn further sub-agents:</p>
<ul>
<li>Read file contents</li>
<li>List directory contents</li>
<li>Search for files</li>
<li>Search the web</li>
</ul>
<p class="note">Intended for genuinely independent research tasks (e.g. "compare these five files" or "look into three unrelated topics"), not everyday questions — the AI is instructed to reserve this for cases that actually benefit from parallelism.</p>
</section>
<section id="keyboard-shortcuts"> <section id="keyboard-shortcuts">
<h2>Keyboard Shortcuts</h2> <h2>Keyboard Shortcuts</h2>
<p>Work faster with these keyboard shortcuts.</p> <p>Work faster with these keyboard shortcuts.</p>
@@ -1431,7 +1535,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<dt><kbd>⌘L</kbd></dt> <dt><kbd>⌘L</kbd></dt>
<dd>Browse Conversations</dd> <dd>Browse Conversations</dd>
<dt><kbd>⌘H</kbd></dt> <dt><kbd>⌘H</kbd></dt>
<dd>Command History</dd> <dd>Command History</dd>
<dt><kbd>⌘M</kbd></dt> <dt><kbd>⌘M</kbd></dt>
@@ -1490,6 +1594,9 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<li>Enable/disable write, delete, move, and bash execution permissions</li> <li>Enable/disable write, delete, move, and bash execution permissions</li>
<li>Configure gitignore respect</li> <li>Configure gitignore respect</li>
<li><strong>Bash Execution</strong> — enable AI shell access, set working directory, timeout, and approval behaviour (see <a href="#bash-execution">Bash Execution</a>)</li> <li><strong>Bash Execution</strong> — enable AI shell access, set working directory, timeout, and approval behaviour (see <a href="#bash-execution">Bash Execution</a>)</li>
<li><strong>Research Agents</strong> — let the AI spawn parallel read-only research sub-agents (see <a href="#research-agents">Research Agents</a>)</li>
<li><strong>External MCP Servers</strong> — connect any stdio MCP server for additional tools (see <a href="#external-mcp">External MCP Servers</a>)</li>
<li><strong>Personal Data</strong> — Calendar, Reminders, and Location &amp; Maps access (see <a href="#personal-data">Personal Data Tools</a>)</li>
</ul> </ul>
<h3>Sync Tab</h3> <h3>Sync Tab</h3>
+250
View File
@@ -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)
}
}
+193
View File
@@ -0,0 +1,193 @@
//
// ConversationMergeService.swift
// oAI
//
// Combine multiple saved conversations into one (simple concatenation or AI-assisted merge)
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import Foundation
import os
enum CombineMode: String, Sendable {
case simple
case ai
}
enum MergeError: LocalizedError {
case tooFewConversations
case noDefaultModel
case noAPIKey
case invalidAIResponse(String)
var errorDescription: String? {
switch self {
case .tooFewConversations:
return "Select at least two conversations to combine."
case .noDefaultModel:
return "No default model is configured. Set one in Settings → General → Default Model."
case .noAPIKey:
return "No API key configured for the default provider. Add one in Settings."
case .invalidAIResponse(let snippet):
return "The model's response could not be parsed into a conversation: \(snippet)"
}
}
}
enum ConversationMergeService {
static func merge(
conversationIds: [UUID],
name: String,
mode: CombineMode,
deleteOriginals: Bool
) async throws -> Conversation {
guard conversationIds.count >= 2 else {
throw MergeError.tooFewConversations
}
let sources: [(Conversation, [Message])] = try conversationIds.compactMap { id in
try DatabaseService.shared.loadConversation(id: id)
}
// The model used in the merged conversation should reflect the most recently used
// model across the *source* conversations never the model that performed the merge.
let latestModelId = sources
.flatMap { $0.1 }
.filter { $0.modelId != nil }
.max { $0.timestamp < $1.timestamp }?
.modelId
let mergedMessages: [Message]
switch mode {
case .simple:
mergedMessages = simpleMerge(sources)
case .ai:
mergedMessages = try await aiMerge(sources)
}
let newConversation = try DatabaseService.shared.saveConversation(
id: UUID(),
name: name,
messages: mergedMessages,
primaryModel: latestModelId
)
if deleteOriginals {
for id in conversationIds {
_ = try? DatabaseService.shared.deleteConversation(id: id)
}
}
Log.db.info("Combined \(conversationIds.count) conversations into '\(name)' (mode: \(mode.rawValue), deleteOriginals: \(deleteOriginals))")
return newConversation
}
private static func simpleMerge(_ sources: [(Conversation, [Message])]) -> [Message] {
sources.flatMap { $0.1 }.sorted { $0.timestamp < $1.timestamp }
}
private struct MergedTurn: Codable {
let role: String
let content: String
}
private static func aiMerge(_ sources: [(Conversation, [Message])]) async throws -> [Message] {
let settings = SettingsService.shared
guard let modelId = settings.defaultModel, !modelId.isEmpty else {
throw MergeError.noDefaultModel
}
guard let provider = ProviderRegistry.shared.getProvider(for: settings.defaultProvider) else {
throw MergeError.noAPIKey
}
let transcript = sources.map { conversation, messages -> String in
let body = messages.map { msg -> String in
let label = msg.role == .user ? "**User:**" : "**Assistant:**"
return "\(label) \(msg.content)"
}.joined(separator: "\n\n")
return "### Conversation: \(conversation.name)\n\n\(body)"
}.joined(separator: "\n\n---\n\n")
let mergePrompt = """
Merge the following saved conversation transcripts into a single, coherent conversation. \
Remove redundant or duplicate exchanges, keep the most informative answer when sources overlap, \
preserve important details from each source, and do not invent facts that were not in the originals.
Respond with ONLY a JSON array of message objects in logical order, each in the form \
{"role": "user" or "assistant", "content": "..."}. Do not include any text outside the JSON array.
\(transcript)
"""
let request = ChatRequest(
messages: [Message(role: .user, content: mergePrompt)],
model: modelId,
stream: false,
maxTokens: 4000,
temperature: 0.3,
topP: nil,
systemPrompt: "You are a helpful assistant that merges chat conversation transcripts into one clean, coherent conversation.",
tools: nil,
onlineMode: false,
imageGeneration: false
)
let response: ChatResponse
do {
response = try await provider.chat(request: request)
} catch {
Log.api.error("Conversation merge AI call failed: \(error.localizedDescription)")
throw error
}
let turns = try parseTurns(from: response.content)
// modelId intentionally left nil here: these messages are a synthesized composite,
// not output from a single source model. The conversation's primaryModel (set by the
// caller from the source conversations) is what drives the model shown in the list.
let base = Date()
return turns.enumerated().map { index, turn in
Message(
role: turn.role == "user" ? .user : .assistant,
content: turn.content,
timestamp: base.addingTimeInterval(TimeInterval(index))
)
}
}
private static func parseTurns(from raw: String) throws -> [MergedTurn] {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if text.hasPrefix("```") {
text = text.components(separatedBy: "\n").dropFirst().joined(separator: "\n")
if text.hasSuffix("```") {
text = String(text.dropLast(3))
}
text = text.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard let data = text.data(using: .utf8),
let turns = try? JSONDecoder().decode([MergedTurn].self, from: data),
!turns.isEmpty else {
throw MergeError.invalidAIResponse(String(raw.prefix(200)))
}
return turns
}
}
+41 -27
View File
@@ -134,15 +134,29 @@ final class DatabaseService: Sendable {
nonisolated static let shared = DatabaseService() nonisolated static let shared = DatabaseService()
private let dbQueue: DatabaseQueue private let dbQueue: DatabaseQueue
private let isoFormatter: ISO8601DateFormatter
// Command history limit - keep most recent 5000 entries // Command history limit - keep most recent 5000 entries
private static let maxHistoryEntries = 5000 private nonisolated static let maxHistoryEntries = 5000
// ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable).
private nonisolated static let isoStyle = Date.ISO8601FormatStyle(
dateSeparator: .dash,
dateTimeSeparator: .standard,
timeSeparator: .colon,
timeZoneSeparator: .colon,
includingFractionalSeconds: true,
timeZone: .gmt
)
private nonisolated static func isoString(from date: Date) -> String {
isoStyle.format(date)
}
private nonisolated static func isoDate(from string: String) -> Date? {
(try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
}
nonisolated private init() { nonisolated private init() {
isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fileManager = FileManager.default let fileManager = FileManager.default
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true) let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
@@ -156,7 +170,7 @@ final class DatabaseService: Sendable {
try! migrator.migrate(dbQueue) try! migrator.migrate(dbQueue)
} }
private var migrator: DatabaseMigrator { private nonisolated var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator() var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in migrator.registerMigration("v1") { db in
@@ -375,7 +389,7 @@ final class DatabaseService: Sendable {
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation { nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))") Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
let now = Date() let now = Date()
let nowString = isoFormatter.string(from: now) let nowString = Self.isoString(from: now)
let convRecord = ConversationRecord( let convRecord = ConversationRecord(
id: id.uuidString, id: id.uuidString,
@@ -394,7 +408,7 @@ final class DatabaseService: Sendable {
content: msg.content, content: msg.content,
tokens: msg.tokens, tokens: msg.tokens,
cost: msg.cost, cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp), timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index, sortOrder: index,
modelId: msg.modelId modelId: msg.modelId
) )
@@ -420,7 +434,7 @@ final class DatabaseService: Sendable {
/// Update an existing conversation in-place: rename it, replace all its messages. /// Update an existing conversation in-place: rename it, replace all its messages.
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws { nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
let nowString = isoFormatter.string(from: Date()) let nowString = Self.isoString(from: Date())
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil } guard msg.role != .system else { return nil }
@@ -431,7 +445,7 @@ final class DatabaseService: Sendable {
content: msg.content, content: msg.content,
tokens: msg.tokens, tokens: msg.tokens,
cost: msg.cost, cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp), timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index, sortOrder: index,
modelId: msg.modelId modelId: msg.modelId
) )
@@ -466,7 +480,7 @@ final class DatabaseService: Sendable {
let messages = messageRecords.compactMap { record -> Message? in let messages = messageRecords.compactMap { record -> Message? in
guard let msgId = UUID(uuidString: record.id), guard let msgId = UUID(uuidString: record.id),
let role = MessageRole(rawValue: record.role), let role = MessageRole(rawValue: record.role),
let timestamp = self.isoFormatter.date(from: record.timestamp) let timestamp = Self.isoDate(from: record.timestamp)
else { return nil } else { return nil }
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1 let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
@@ -484,8 +498,8 @@ final class DatabaseService: Sendable {
} }
guard let convId = UUID(uuidString: convRecord.id), guard let convId = UUID(uuidString: convRecord.id),
let createdAt = self.isoFormatter.date(from: convRecord.createdAt), let createdAt = Self.isoDate(from: convRecord.createdAt),
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt) let updatedAt = Self.isoDate(from: convRecord.updatedAt)
else { return nil } else { return nil }
let conversation = Conversation( let conversation = Conversation(
@@ -509,8 +523,8 @@ final class DatabaseService: Sendable {
return records.compactMap { record -> Conversation? in return records.compactMap { record -> Conversation? in
guard let id = UUID(uuidString: record.id), guard let id = UUID(uuidString: record.id),
let createdAt = self.isoFormatter.date(from: record.createdAt), let createdAt = Self.isoDate(from: record.createdAt),
let updatedAt = self.isoFormatter.date(from: record.updatedAt) let updatedAt = Self.isoDate(from: record.updatedAt)
else { return nil } else { return nil }
// Fetch message count without loading all messages // Fetch message count without loading all messages
@@ -524,7 +538,7 @@ final class DatabaseService: Sendable {
.order(Column("sortOrder").desc) .order(Column("sortOrder").desc)
.fetchOne(db) .fetchOne(db)
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt
// Derive primary model: prefer the stored field, fall back to last message's modelId // Derive primary model: prefer the stored field, fall back to last message's modelId
let primaryModel = record.primaryModel ?? lastMsg?.modelId let primaryModel = record.primaryModel ?? lastMsg?.modelId
@@ -574,7 +588,7 @@ final class DatabaseService: Sendable {
convRecord.name = name convRecord.name = name
} }
convRecord.updatedAt = self.isoFormatter.string(from: Date()) convRecord.updatedAt = Self.isoString(from: Date())
try convRecord.update(db) try convRecord.update(db)
if let messages = messages { if let messages = messages {
@@ -589,7 +603,7 @@ final class DatabaseService: Sendable {
content: msg.content, content: msg.content,
tokens: msg.tokens, tokens: msg.tokens,
cost: msg.cost, cost: msg.cost,
timestamp: self.isoFormatter.string(from: msg.timestamp), timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index sortOrder: index
) )
} }
@@ -610,7 +624,7 @@ final class DatabaseService: Sendable {
let record = HistoryRecord( let record = HistoryRecord(
id: UUID().uuidString, id: UUID().uuidString,
input: input, input: input,
timestamp: isoFormatter.string(from: now) timestamp: Self.isoString(from: now)
) )
try? dbQueue.write { db in try? dbQueue.write { db in
@@ -643,7 +657,7 @@ final class DatabaseService: Sendable {
.fetchAll(db) .fetchAll(db)
return records.compactMap { record in return records.compactMap { record in
guard let date = isoFormatter.date(from: record.timestamp) else { guard let date = Self.isoDate(from: record.timestamp) else {
return nil return nil
} }
return (input: record.input, timestamp: date) return (input: record.input, timestamp: date)
@@ -659,7 +673,7 @@ final class DatabaseService: Sendable {
.fetchAll(db) .fetchAll(db)
return records.compactMap { record in return records.compactMap { record in
guard let date = isoFormatter.date(from: record.timestamp) else { guard let date = Self.isoDate(from: record.timestamp) else {
return nil return nil
} }
return (input: record.input, timestamp: date) return (input: record.input, timestamp: date)
@@ -672,7 +686,7 @@ final class DatabaseService: Sendable {
nonisolated func saveEmailLog(_ log: EmailLog) { nonisolated func saveEmailLog(_ log: EmailLog) {
let record = EmailLogRecord( let record = EmailLogRecord(
id: log.id.uuidString, id: log.id.uuidString,
timestamp: isoFormatter.string(from: log.timestamp), timestamp: Self.isoString(from: log.timestamp),
sender: log.sender, sender: log.sender,
subject: log.subject, subject: log.subject,
emailContent: log.emailContent, emailContent: log.emailContent,
@@ -698,7 +712,7 @@ final class DatabaseService: Sendable {
.fetchAll(db) .fetchAll(db)
return records.compactMap { record in return records.compactMap { record in
guard let timestamp = isoFormatter.date(from: record.timestamp), guard let timestamp = Self.isoDate(from: record.timestamp),
let status = EmailLogStatus(rawValue: record.status), let status = EmailLogStatus(rawValue: record.status),
let id = UUID(uuidString: record.id) else { let id = UUID(uuidString: record.id) else {
return nil return nil
@@ -805,7 +819,7 @@ final class DatabaseService: Sendable {
// MARK: - Embedding Operations // MARK: - Embedding Operations
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws { nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
let now = isoFormatter.string(from: Date()) let now = Self.isoString(from: Date())
let record = MessageEmbeddingRecord( let record = MessageEmbeddingRecord(
message_id: messageId.uuidString, message_id: messageId.uuidString,
embedding: embedding, embedding: embedding,
@@ -825,7 +839,7 @@ final class DatabaseService: Sendable {
} }
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws { nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
let now = isoFormatter.string(from: Date()) let now = Self.isoString(from: Date())
let record = ConversationEmbeddingRecord( let record = ConversationEmbeddingRecord(
conversation_id: conversationId.uuidString, conversation_id: conversationId.uuidString,
embedding: embedding, embedding: embedding,
@@ -881,7 +895,7 @@ final class DatabaseService: Sendable {
return Array(results.prefix(limit)) return Array(results.prefix(limit))
} }
private func deserializeEmbedding(_ data: Data) -> [Float] { private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
var embedding: [Float] = [] var embedding: [Float] = []
embedding.reserveCapacity(data.count / 4) embedding.reserveCapacity(data.count / 4)
@@ -905,7 +919,7 @@ final class DatabaseService: Sendable {
model: String?, model: String?,
tokenCount: Int? tokenCount: Int?
) throws { ) throws {
let now = isoFormatter.string(from: Date()) let now = Self.isoString(from: Date())
let record = ConversationSummaryRecord( let record = ConversationSummaryRecord(
id: UUID().uuidString, id: UUID().uuidString,
conversation_id: conversationId.uuidString, conversation_id: conversationId.uuidString,
+2 -2
View File
@@ -71,7 +71,7 @@ enum EmbeddingProvider {
// MARK: - Embedding Service // MARK: - Embedding Service
final class EmbeddingService { final class EmbeddingService {
static let shared = EmbeddingService() nonisolated static let shared = EmbeddingService()
private let settings = SettingsService.shared private let settings = SettingsService.shared
@@ -281,7 +281,7 @@ final class EmbeddingService {
// MARK: - Similarity Calculation // MARK: - Similarity Calculation
/// Calculate cosine similarity between two embeddings /// Calculate cosine similarity between two embeddings
func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { nonisolated func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
guard a.count == b.count else { guard a.count == b.count else {
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)") Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
return 0.0 return 0.0
+10 -13
View File
@@ -29,19 +29,18 @@ import CryptoKit
import IOKit import IOKit
class EncryptionService { class EncryptionService {
static let shared = EncryptionService() nonisolated static let shared = EncryptionService()
private let salt = "oAI-secure-storage-v1" // App-specific salt private let encryptionKey: SymmetricKey
private lazy var encryptionKey: SymmetricKey = {
deriveEncryptionKey()
}()
private init() {} private init() {
self.encryptionKey = Self.deriveEncryptionKey()
}
// MARK: - Public Interface // MARK: - Public Interface
/// Encrypt a string value /// Encrypt a string value
func encrypt(_ value: String) throws -> String { nonisolated func encrypt(_ value: String) throws -> String {
guard let data = value.data(using: .utf8) else { guard let data = value.data(using: .utf8) else {
throw EncryptionError.invalidInput throw EncryptionError.invalidInput
} }
@@ -55,7 +54,7 @@ class EncryptionService {
} }
/// Decrypt a string value /// Decrypt a string value
func decrypt(_ encryptedValue: String) throws -> String { nonisolated func decrypt(_ encryptedValue: String) throws -> String {
guard let data = Data(base64Encoded: encryptedValue) else { guard let data = Data(base64Encoded: encryptedValue) else {
throw EncryptionError.invalidInput throw EncryptionError.invalidInput
} }
@@ -73,19 +72,17 @@ class EncryptionService {
// MARK: - Key Derivation // MARK: - Key Derivation
/// Derive encryption key from machine-specific data /// Derive encryption key from machine-specific data
private func deriveEncryptionKey() -> SymmetricKey { private static func deriveEncryptionKey() -> SymmetricKey {
// Combine machine UUID + bundle ID + salt for key material
let machineUUID = getMachineUUID() let machineUUID = getMachineUUID()
let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI" let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI"
let salt = "oAI-secure-storage-v1"
let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)" let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)"
// Hash to create consistent 256-bit key
let hash = SHA256.hash(data: Data(keyMaterial.utf8)) let hash = SHA256.hash(data: Data(keyMaterial.utf8))
return SymmetricKey(data: hash) return SymmetricKey(data: hash)
} }
/// Get machine-specific UUID (IOPlatformUUID) /// Get machine-specific UUID (IOPlatformUUID)
private func getMachineUUID() -> String { private static func getMachineUUID() -> String {
// Get IOPlatformUUID from IOKit // Get IOPlatformUUID from IOKit
let platformExpert = IOServiceGetMatchingService( let platformExpert = IOServiceGetMatchingService(
kIOMainPortDefault, kIOMainPortDefault,
+593
View File
@@ -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
}
}
+280
View File
@@ -0,0 +1,280 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import Foundation
// MARK: - ExternalMCPClient
/// Manages one MCP stdio server process. All state is MainActor-isolated
/// (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor project setting).
/// Background I/O runs in Task.detached; state mutations hop back to MainActor.
@MainActor
final class ExternalMCPClient {
let server: ExternalMCPServer
weak var stateDelegate: (any ExternalMCPStateDelegate)?
private var process: Process?
private var stdinHandle: FileHandle?
private var readTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
private var nextRequestId: Int = 1
private var pendingCalls: [Int: CheckedContinuation<Data, Error>] = [:]
private var lineBuffer = Data()
private(set) var state: MCPClientState = .idle
private(set) var discoveredTools: [MCPToolDefinition] = []
init(server: ExternalMCPServer, stateDelegate: (any ExternalMCPStateDelegate)?) {
self.server = server
self.stateDelegate = stateDelegate
}
// MARK: - Lifecycle
func start() async throws {
guard state == .idle || state == .stopped || state == .crashed else { return }
state = .connecting
stateDelegate?.clientDidChangeState(id: server.id, state: .connecting)
let proc = Process()
if server.command.hasPrefix("/") {
proc.executableURL = URL(fileURLWithPath: server.command)
proc.arguments = server.args
} else {
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
proc.arguments = [server.command] + server.args
}
proc.environment = ProcessInfo.processInfo.environment
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardInput = stdinPipe
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
proc.terminationHandler = { [weak self] _ in
Task { @MainActor [weak self] in self?.handleProcessTerminated() }
}
do {
try proc.run()
} catch {
state = .error(error.localizedDescription)
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
throw MCPClientError.processLaunchFailed(error.localizedDescription)
}
process = proc
stdinHandle = stdinPipe.fileHandleForWriting
startReadLoop(pipe: stdoutPipe)
startStderrLoop(pipe: stderrPipe)
do {
let _: MCPInitializeResult = try await timedRequest(seconds: 15, method: "initialize", params: [
"protocolVersion": "2024-11-05",
"capabilities": [:] as [String: Any],
"clientInfo": ["name": "oAI", "version": "1.0"] as [String: Any]
])
try sendNotification(method: "notifications/initialized")
let toolsResult: MCPToolsListResult = try await timedRequest(seconds: 15, method: "tools/list", params: nil)
discoveredTools = toolsResult.tools
} catch {
state = .error(error.localizedDescription)
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
proc.terminate()
throw error
}
state = .ready
stateDelegate?.clientDidBecomeReady(id: server.id, tools: discoveredTools, server: server)
}
func stop() {
state = .stopped
readTask?.cancel()
stderrTask?.cancel()
process?.terminate()
process = nil
stdinHandle = nil
lineBuffer = Data()
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
}
// MARK: - Tool Execution
func callTool(originalName: String, argumentsJSON: String) async -> [String: Any] {
guard state == .ready else {
return ["error": "MCP server '\(server.name)' is not connected"]
}
guard let argData = argumentsJSON.data(using: .utf8),
let argsDict = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
return ["error": "Invalid arguments JSON for tool \(originalName)"]
}
do {
let result: MCPToolCallResult = try await timedRequest(
seconds: server.timeout,
method: "tools/call",
params: ["name": originalName, "arguments": argsDict]
)
return convertMCPResult(result)
} catch MCPClientError.timeout {
return ["error": "MCP server '\(server.name)' timed out after \(Int(server.timeout))s"]
} catch {
return ["error": "MCP call '\(originalName)' failed: \(error.localizedDescription)"]
}
}
// MARK: - I/O Loops (detached from MainActor)
private func startReadLoop(pipe: Pipe) {
readTask = Task.detached { [weak self] in
let handle = pipe.fileHandleForReading
while true {
let data = handle.availableData
if data.isEmpty { break }
await self?.receiveData(data)
}
}
}
private func startStderrLoop(pipe: Pipe) {
let name = server.name
stderrTask = Task.detached {
let handle = pipe.fileHandleForReading
var buf = Data()
while true {
let data = handle.availableData
if data.isEmpty { break }
buf.append(data)
while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) {
let line = String(data: buf[buf.startIndex..<idx], encoding: .utf8) ?? ""
buf = Data(buf[buf.index(after: idx)...])
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
Log.extMcp.warning("[\(name)] \(line)")
}
}
}
}
}
// MARK: - Data Processing (MainActor)
private func receiveData(_ data: Data) {
lineBuffer.append(data)
while let idx = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = Data(lineBuffer[lineBuffer.startIndex..<idx])
lineBuffer = Data(lineBuffer[lineBuffer.index(after: idx)...])
processLine(lineData)
}
}
private func processLine(_ data: Data) {
guard !data.isEmpty,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = json["id"] as? Int,
let cont = pendingCalls.removeValue(forKey: id) else { return }
if let err = json["error"] as? [String: Any] {
cont.resume(throwing: MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error"))
} else if let result = json["result"],
let resultData = try? JSONSerialization.data(withJSONObject: result) {
cont.resume(returning: resultData)
} else {
cont.resume(throwing: MCPClientError.invalidResponse("Missing result field"))
}
}
// MARK: - JSON-RPC
/// Send a JSON-RPC request with a per-call timeout. The timeout fires a cancellation
/// directly into the pending-calls table rather than using a task group (which would
/// pass the generic T through a @Sendable closure and trigger an isolated-conformance warning).
private func timedRequest<T: Decodable>(seconds: Double, method: String, params: [String: Any]?) async throws -> T {
let id = nextRequestId
nextRequestId += 1
var message: [String: Any] = ["jsonrpc": "2.0", "method": method, "id": id]
if let params { message["params"] = params }
try writeJSON(message)
// Schedule timeout: cancels the specific pending call by ID
let timeoutId = id
Task { [weak self, timeoutId] in
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout)
}
// Await response data, then decode on MainActor
let resultData: Data = try await withCheckedThrowingContinuation { cont in
pendingCalls[id] = cont
}
return try JSONDecoder().decode(T.self, from: resultData)
}
private func cancelPendingCall(id: Int, with error: Error) {
pendingCalls.removeValue(forKey: id)?.resume(throwing: error)
}
private func sendNotification(method: String) throws {
try writeJSON(["jsonrpc": "2.0", "method": method])
}
private func writeJSON(_ message: [String: Any]) throws {
guard let handle = stdinHandle, process?.isRunning == true else {
throw MCPClientError.writeFailed
}
guard let data = try? JSONSerialization.data(withJSONObject: message),
let line = String(data: data, encoding: .utf8) else {
throw MCPClientError.writeFailed
}
do {
try handle.write(contentsOf: Data((line + "\n").utf8))
} catch {
throw MCPClientError.writeFailed
}
}
// MARK: - Process termination
private func handleProcessTerminated() {
guard state != .stopped else { return }
state = .crashed
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
}
// MARK: - Result conversion
private func convertMCPResult(_ result: MCPToolCallResult) -> [String: Any] {
let isError = result.isError ?? false
var parts: [String] = []
for content in result.content {
switch content.type {
case "text":
if let text = content.text { parts.append(text) }
case "image":
if let base64 = content.data, let imageData = Data(base64Encoded: base64) {
parts.append("[Image saved to: \(writeTempImage(imageData, mimeType: content.mimeType))]")
}
case "resource":
if let text = content.text { parts.append(text) }
else if let uri = content.uri { parts.append("[Resource: \(uri)]") }
default:
if let text = content.text { parts.append(text) }
}
}
let combined = parts.joined(separator: "\n")
return isError ? ["error": combined.isEmpty ? "Tool returned an error" : combined] : ["output": combined]
}
private func writeTempImage(_ data: Data, mimeType: String?) -> String {
let ext = mimeType?.contains("png") == true ? "png" : "jpg"
let path = "/tmp/oai_mcp_\(Int(Date().timeIntervalSince1970 * 1000)).\(ext)"
try? data.write(to: URL(fileURLWithPath: path))
return path
}
}
+202
View File
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import Foundation
// MARK: - ExternalMCPManager
@Observable
@MainActor
final class ExternalMCPManager {
nonisolated static let shared = ExternalMCPManager()
private(set) var clientStates: [UUID: MCPClientState] = [:]
private(set) var cachedToolSchemas: [Tool] = []
// Keep server config alongside client so we can access slug without await
private var clients: [UUID: ExternalMCPClient] = [:]
private var serverConfigs: [UUID: ExternalMCPServer] = [:]
private var restartTasks: [UUID: Task<Void, Never>] = [:]
private var restartAttempts: [UUID: Int] = [:]
private nonisolated init() {}
// MARK: - Lifecycle
func startAll() {
for server in SettingsService.shared.externalMCPServers where server.isEnabled {
startClient(for: server)
}
}
func stopAll() {
for client in clients.values { client.stop() }
clients.removeAll()
serverConfigs.removeAll()
clientStates.removeAll()
cachedToolSchemas.removeAll()
for task in restartTasks.values { task.cancel() }
restartTasks.removeAll()
restartAttempts.removeAll()
}
func reconfigure(servers: [ExternalMCPServer]) {
let activeIds = Set(servers.filter { $0.isEnabled }.map { $0.id })
for id in clients.keys where !activeIds.contains(id) {
clients[id]?.stop()
clients.removeValue(forKey: id)
serverConfigs.removeValue(forKey: id)
clientStates.removeValue(forKey: id)
restartTasks[id]?.cancel()
restartTasks.removeValue(forKey: id)
restartAttempts.removeValue(forKey: id)
removeCachedSchemas(for: id)
}
for server in servers where server.isEnabled && clients[server.id] == nil {
startClient(for: server)
}
}
private func startClient(for server: ExternalMCPServer) {
// Stop any existing client for this ID before creating a new one
clients[server.id]?.stop()
let client = ExternalMCPClient(server: server, stateDelegate: self)
clients[server.id] = client
serverConfigs[server.id] = server
clientStates[server.id] = .connecting
Task {
do {
try await client.start()
} catch MCPClientError.processLaunchFailed(let msg) {
// Process never started termination handler won't fire, so manually trigger crashed
Log.extMcp.error("Failed to launch '\(server.name)': \(msg)")
clientDidChangeState(id: server.id, state: .crashed)
} catch {
// Handshake/other failure proc.terminate() was called in start(), termination
// handler will fire and set .crashed, which drives the restart from one place only.
Log.extMcp.warning("'\(server.name)' start failed: \(error.localizedDescription)")
}
}
}
private func scheduleRestart(for server: ExternalMCPServer, attempt: Int) {
let delays: [Double] = [5, 15, 30]
let delay = delays[min(attempt - 1, delays.count - 1)]
Log.extMcp.warning("MCP server '\(server.name)' crashed — restarting in \(Int(delay))s (attempt \(attempt)/3)")
restartTasks[server.id]?.cancel()
let id = server.id
restartTasks[id] = Task { [weak self, id] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled, let self,
self.clients[id] != nil,
SettingsService.shared.externalMCPServers.contains(where: { $0.id == id && $0.isEnabled })
else { return }
// startClient is the single place that creates and launches clients.
// It handles processLaunchFailed by calling clientDidChangeState(.crashed),
// and all other failures let the termination handler drive the .crashed callback.
self.startClient(for: server)
}
}
// MARK: - Tool Schema Integration (synchronous)
func getToolSchemas() -> [Tool] { cachedToolSchemas }
func isExternalTool(_ name: String) -> Bool {
cachedToolSchemas.contains { $0.function.name == name }
}
// MARK: - Tool Execution
func executeTool(name: String, argumentsJSON: String) async -> [String: Any] {
for (id, client) in clients {
guard clientStates[id] == .ready,
let server = serverConfigs[id] else { continue }
let prefix = "\(server.slug)_"
if name.hasPrefix(prefix) {
let originalName = String(name.dropFirst(prefix.count))
return await client.callTool(originalName: originalName, argumentsJSON: argumentsJSON)
}
}
return ["error": "No external MCP server found for tool: \(name)"]
}
// MARK: - Schema Cache
private func rebuildCache(for server: ExternalMCPServer, tools: [MCPToolDefinition]) {
removeCachedSchemas(for: server.id, slug: server.slug)
let prefixed = tools.compactMap { convertToolDefinition($0, server: server) }
cachedToolSchemas.append(contentsOf: prefixed)
Log.extMcp.info("[\(server.name)] cached \(prefixed.count) tools: \(prefixed.map { $0.function.name }.joined(separator: ", "))")
}
private func removeCachedSchemas(for id: UUID) {
guard let server = serverConfigs[id] else { return }
removeCachedSchemas(for: id, slug: server.slug)
}
private func removeCachedSchemas(for id: UUID, slug: String) {
cachedToolSchemas.removeAll { $0.function.name.hasPrefix("\(slug)_") }
}
private func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
Tool(
type: "function",
function: Tool.Function(
name: "\(server.slug)_\(def.name)",
description: "[\(server.name)] \(def.description ?? "")",
parameters: convertInputSchema(def.inputSchema)
)
)
}
private func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
var properties: [String: Tool.Function.Parameters.Property] = [:]
for (key, prop) in schema.properties ?? [:] {
let normalized: String
switch prop.type ?? "string" {
case "integer": normalized = "number"
case "string", "number", "boolean", "array", "object": normalized = prop.type!
default: normalized = "string"
}
var items: Tool.Function.Parameters.Property.Items? = nil
if normalized == "array", let t = prop.items?.type { items = .init(type: t) }
properties[key] = Tool.Function.Parameters.Property(
type: normalized,
description: prop.description ?? "",
enum: prop.enum,
items: items
)
}
return Tool.Function.Parameters(type: "object", properties: properties, required: schema.required)
}
}
// MARK: - ExternalMCPStateDelegate
extension ExternalMCPManager: ExternalMCPStateDelegate {
func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer) {
clientStates[id] = .ready
restartAttempts.removeValue(forKey: id)
rebuildCache(for: server, tools: tools)
}
func clientDidChangeState(id: UUID, state: MCPClientState) {
clientStates[id] = state
if case .crashed = state,
let server = serverConfigs[id],
SettingsService.shared.externalMCPServers.contains(where: { $0.id == id && $0.isEnabled }) {
removeCachedSchemas(for: id, slug: server.slug)
let attempt = (restartAttempts[id] ?? 0) + 1
guard attempt <= 3 else {
Log.extMcp.error("MCP server '\(server.name)' gave up after 3 restart attempts")
clientStates[id] = .error("Maximum restart attempts reached")
restartAttempts.removeValue(forKey: id)
return
}
restartAttempts[id] = attempt
scheduleRestart(for: server, attempt: attempt)
}
}
}
+170
View File
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import Foundation
// MARK: - Server Configuration
struct ExternalMCPServer: Codable, Identifiable, Sendable {
var id: UUID
var name: String
var command: String
var args: [String]
var isEnabled: Bool
var timeout: TimeInterval
var createdAt: Date
init(id: UUID = UUID(), name: String, command: String, args: [String] = [],
isEnabled: Bool = true, timeout: TimeInterval = 30, createdAt: Date = Date()) {
self.id = id
self.name = name
self.command = command
self.args = args
self.isEnabled = isEnabled
self.timeout = timeout
self.createdAt = createdAt
}
var slug: String { Self.makeSlug(from: name) }
static func makeSlug(from name: String) -> String {
let s = name
.lowercased()
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
.joined(separator: "_")
return s.isEmpty ? "ext" : s
}
/// Splits a raw arguments string into tokens, respecting single/double-quoted
/// segments so arguments containing spaces (e.g. `--root "/Users/x/My Documents"`)
/// survive intact instead of being split on every space.
static func parseArguments(_ input: String) -> [String] {
var args: [String] = []
var current = ""
var inSingleQuotes = false
var inDoubleQuotes = false
for char in input {
if char == "'" && !inDoubleQuotes {
inSingleQuotes.toggle()
} else if char == "\"" && !inSingleQuotes {
inDoubleQuotes.toggle()
} else if char.isWhitespace && !inSingleQuotes && !inDoubleQuotes {
if !current.isEmpty {
args.append(current)
current = ""
}
} else {
current.append(char)
}
}
if !current.isEmpty { args.append(current) }
return args
}
static let reservedSlugs: Set<String> = [
"anytype", "paperless", "calendar", "reminders",
"contacts", "location", "maps", "bash", "web", "read", "write",
"list", "search", "edit", "delete", "create", "move", "copy", "spawn"
]
var isSlugReserved: Bool { Self.reservedSlugs.contains(slug) }
}
// MARK: - Client State
enum MCPClientState: Equatable {
case idle
case connecting
case ready
case error(String)
case crashed
case stopped
}
// MARK: - State Delegate (all callbacks on MainActor)
@MainActor
protocol ExternalMCPStateDelegate: AnyObject {
func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer)
func clientDidChangeState(id: UUID, state: MCPClientState)
}
// MARK: - Client Errors
enum MCPClientError: LocalizedError {
case notConnected
case invalidResponse(String)
case timeout
case processLaunchFailed(String)
case handshakeFailed(String)
case writeFailed
var errorDescription: String? {
switch self {
case .notConnected: return "MCP server is not connected"
case .invalidResponse(let s): return "Invalid MCP response: \(s)"
case .timeout: return "MCP request timed out"
case .processLaunchFailed(let s): return "Failed to launch MCP server: \(s)"
case .handshakeFailed(let s): return "MCP handshake failed: \(s)"
case .writeFailed: return "Failed to write to MCP server stdin"
}
}
}
// MARK: - MCP Protocol Types
struct MCPInitializeResult: Decodable {
let protocolVersion: String
let capabilities: MCPCapabilities
let serverInfo: MCPServerInfo?
}
struct MCPCapabilities: Decodable {
let tools: MCPToolsCapability?
struct MCPToolsCapability: Decodable { let listChanged: Bool? }
}
struct MCPServerInfo: Decodable {
let name: String
let version: String?
}
struct MCPToolsListResult: Decodable {
let tools: [MCPToolDefinition]
let nextCursor: String?
}
struct MCPToolDefinition: Decodable {
let name: String
let description: String?
let inputSchema: MCPInputSchema
}
struct MCPInputSchema: Decodable {
let type: String
let properties: [String: MCPPropertySchema]?
let required: [String]?
}
struct MCPPropertySchema: Decodable {
let type: String?
let description: String?
let `enum`: [String]?
let items: MCPItemsSchema?
struct MCPItemsSchema: Decodable { let type: String? }
}
struct MCPToolCallResult: Decodable {
let content: [MCPContent]
let isError: Bool?
}
struct MCPContent: Decodable {
let type: String
let text: String?
let data: String?
let mimeType: String?
let uri: String?
}
+1 -1
View File
@@ -212,7 +212,7 @@ class GitSyncService {
// Check if conversation already exists (by ID) // Check if conversation already exists (by ID)
if let existingId = UUID(uuidString: export.id) { if let existingId = UUID(uuidString: export.id) {
if let existing = try? db.loadConversation(id: existingId) { if (try? db.loadConversation(id: existingId)) != nil {
// Already exists - skip // Already exists - skip
log.debug("Skipping existing conversation: \(export.name)") log.debug("Skipping existing conversation: \(export.name)")
skipped += 1 skipped += 1
+343
View File
@@ -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)
}
}
+292 -1
View File
@@ -98,6 +98,17 @@ class MCPService {
func isPathAllowed(_ path: String) -> Bool { func isPathAllowed(_ path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
// Always allow the system temp directory external MCP servers and tools write
// intermediate data there (e.g. Safari MCP page-content files, generated images).
// Check both NSTemporaryDirectory() (per-user Darwin temp dir) and /tmp (what this
// codebase's own temp files actually use, e.g. ChatViewModel's /tmp/oai_generated_*
// and ExternalMCPClient's /tmp/oai_mcp_* they resolve to different directories.
let tmpCandidates = [
(NSTemporaryDirectory() as NSString).standardizingPath,
"/tmp",
"/private/tmp"
]
if tmpCandidates.contains(where: { resolved.hasPrefix($0) }) { return true }
return allowedFolders.contains { resolved.hasPrefix($0) } return allowedFolders.contains { resolved.hasPrefix($0) }
} }
@@ -111,6 +122,9 @@ class MCPService {
private let anytypeService = AnytypeMCPService.shared private let anytypeService = AnytypeMCPService.shared
private let paperlessService = PaperlessService.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 // MARK: - Bash Approval State
@@ -124,6 +138,19 @@ class MCPService {
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
private(set) var bashSessionApproved: Bool = false 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 // MARK: - Tool Schema Generation
func getToolSchemas(onlineMode: Bool = false) -> [Tool] { func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
@@ -232,6 +259,24 @@ class MCPService {
tools.append(contentsOf: paperlessService.getToolSchemas()) 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 // Add bash_execute tool when bash is enabled
if settings.bashEnabled { if settings.bashEnabled {
let workDir = settings.bashWorkingDirectory let workDir = settings.bashWorkingDirectory
@@ -260,6 +305,25 @@ 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"]
))
}
// Add tools from external MCP servers (stdio JSON-RPC protocol)
tools.append(contentsOf: ExternalMCPManager.shared.getToolSchemas())
return tools return tools
} }
@@ -284,7 +348,10 @@ class MCPService {
// MARK: - Tool Execution // 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)") Log.mcp.info("Executing tool: \(name)")
guard let argData = arguments.data(using: .utf8), guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else { let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
@@ -399,7 +466,30 @@ class MCPService {
let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] } let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] }
return ["results": mapped] 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: default:
// Route to external MCP servers (stdio JSON-RPC)
if ExternalMCPManager.shared.isExternalTool(name) {
return await ExternalMCPManager.shared.executeTool(name: name, argumentsJSON: arguments)
}
// Route anytype_* tools to AnytypeMCPService // Route anytype_* tools to AnytypeMCPService
if name.hasPrefix("anytype_") { if name.hasPrefix("anytype_") {
return await anytypeService.executeTool(name: name, arguments: arguments) return await anytypeService.executeTool(name: name, arguments: arguments)
@@ -408,6 +498,18 @@ class MCPService {
if name.hasPrefix("paperless_") { if name.hasPrefix("paperless_") {
return await paperlessService.executeTool(name: name, arguments: arguments) 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)"] return ["error": "Unknown tool: \(name)"]
} }
} }
@@ -754,6 +856,11 @@ class MCPService {
if bashSessionApproved { if bashSessionApproved {
return await runBashCommand(command, workingDirectory: workingDirectory) 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 return await withCheckedContinuation { continuation in
DispatchQueue.main.async { DispatchQueue.main.async {
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory) self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
@@ -786,6 +893,190 @@ class MCPService {
bashSessionApproved = false 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] { private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
let timeoutSeconds = settings.bashTimeout let timeoutSeconds = settings.bashTimeout
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
+145
View File
@@ -27,6 +27,18 @@ import Foundation
import os import os
import Security 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 @Observable
class SettingsService { class SettingsService {
static let shared = SettingsService() static let shared = SettingsService()
@@ -300,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 // MARK: - MCP Permissions
var mcpCanWriteFiles: Bool { var mcpCanWriteFiles: Bool {
@@ -431,6 +461,48 @@ class SettingsService {
} }
} }
// MARK: - External MCP Servers
var externalMCPServers: [ExternalMCPServer] {
get {
guard let json = cache["externalMCPServers"],
let data = json.data(using: .utf8) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([ExternalMCPServer].self, from: data)) ?? []
}
set {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(newValue),
let json = String(data: data, encoding: .utf8) {
cache["externalMCPServers"] = json
DatabaseService.shared.setSetting(key: "externalMCPServers", value: json)
}
Task { @MainActor in ExternalMCPManager.shared.reconfigure(servers: newValue) }
}
}
func addExternalMCPServer(_ server: ExternalMCPServer) {
externalMCPServers = externalMCPServers + [server]
}
func updateExternalMCPServer(_ server: ExternalMCPServer) {
externalMCPServers = externalMCPServers.map { $0.id == server.id ? server : $0 }
}
func deleteExternalMCPServer(id: UUID) {
externalMCPServers = externalMCPServers.filter { $0.id != id }
}
func toggleExternalMCPServer(id: UUID) {
externalMCPServers = externalMCPServers.map { s in
s.id == id ? ExternalMCPServer(id: s.id, name: s.name, command: s.command,
args: s.args, isEnabled: !s.isEnabled,
timeout: s.timeout, createdAt: s.createdAt) : s
}
}
// MARK: - Favorite Models // MARK: - Favorite Models
var favoriteModelIds: Set<String> { var favoriteModelIds: Set<String> {
@@ -559,6 +631,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 { var bashWorkingDirectory: String {
get { cache["bashWorkingDirectory"] ?? "~" } get { cache["bashWorkingDirectory"] ?? "~" }
set { set {
@@ -575,6 +678,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 // MARK: - Paperless-NGX Settings
var paperlessEnabled: Bool { var paperlessEnabled: Bool {
+30
View File
@@ -35,6 +35,11 @@ final class UpdateCheckService {
var updateAvailable: Bool = false var updateAvailable: Bool = false
var latestVersion: String? = nil var latestVersion: String? = nil
var downloadURL: URL? = nil
// Manual check state drives the update alert in ContentView
var isCheckingManually: Bool = false
var manualCheckMessage: String? = nil
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest" private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")! private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
@@ -48,6 +53,24 @@ final class UpdateCheckService {
} }
} }
/// Manual check triggered from the Help menu. Non-blocking result surfaces via manualCheckMessage.
func checkForUpdatesManually() {
guard !isCheckingManually else { return }
isCheckingManually = true
Task.detached(priority: .background) {
await self.performCheck()
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
await MainActor.run {
if self.updateAvailable, let v = self.latestVersion {
self.manualCheckMessage = String(localized: "Version \(v) is available.")
} else {
self.manualCheckMessage = String(localized: "You're up to date (v\(current)).")
}
self.isCheckingManually = false
}
}
}
private func performCheck() async { private func performCheck() async {
guard let url = URL(string: apiURL) else { return } guard let url = URL(string: apiURL) else { return }
@@ -69,9 +92,16 @@ final class UpdateCheckService {
let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
// Extract direct DMG download URL from release assets
let dmgURL: URL? = (release["assets"] as? [[String: Any]])?
.first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") }
.flatMap { $0["browser_download_url"] as? String }
.flatMap { URL(string: $0) }
if isNewer(latestVer, than: currentVer) { if isNewer(latestVer, than: currentVer) {
await MainActor.run { await MainActor.run {
self.latestVersion = latestVer self.latestVersion = latestVer
self.downloadURL = dmgURL
self.updateAvailable = true self.updateAvailable = true
} }
} }
+26 -23
View File
@@ -52,7 +52,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable {
} }
} }
static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { nonisolated static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
lhs.rawValue < rhs.rawValue lhs.rawValue < rhs.rawValue
} }
} }
@@ -60,7 +60,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable {
// MARK: - File Logger // MARK: - File Logger
final class FileLogger: @unchecked Sendable { final class FileLogger: @unchecked Sendable {
static let shared = FileLogger() nonisolated static let shared = FileLogger()
private let fileHandle: FileHandle? private let fileHandle: FileHandle?
private let queue = DispatchQueue(label: "com.oai.filelogger") private let queue = DispatchQueue(label: "com.oai.filelogger")
@@ -70,8 +70,8 @@ final class FileLogger: @unchecked Sendable {
return f return f
}() }()
/// Current minimum log level (read from UserDefaults for thread safety) /// Current minimum log level (backed by UserDefaults thread-safe).
var minimumLevel: LogLevel { nonisolated var minimumLevel: LogLevel {
get { get {
let raw = UserDefaults.standard.integer(forKey: "logLevel") let raw = UserDefaults.standard.integer(forKey: "logLevel")
return LogLevel(rawValue: raw) ?? .info return LogLevel(rawValue: raw) ?? .info
@@ -95,7 +95,7 @@ final class FileLogger: @unchecked Sendable {
fileHandle?.seekToEndOfFile() fileHandle?.seekToEndOfFile()
} }
func write(_ level: LogLevel, category: String, message: String) { nonisolated func write(_ level: LogLevel, category: String, message: String) {
guard level >= minimumLevel else { return } guard level >= minimumLevel else { return }
queue.async { [weak self] in queue.async { [weak self] in
guard let self, let fh = self.fileHandle else { return } guard let self, let fh = self.fileHandle else { return }
@@ -114,41 +114,44 @@ final class FileLogger: @unchecked Sendable {
// MARK: - App Logger (wraps os.Logger + file) // MARK: - App Logger (wraps os.Logger + file)
struct AppLogger { // os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are
let osLogger: Logger // nonisolated FileLogger runs on its own serial queue, os.Logger dispatches to main actor.
struct AppLogger: Sendable {
let subsystem: String
let category: String let category: String
func debug(_ message: String) { nonisolated func debug(_ message: String) {
FileLogger.shared.write(.debug, category: category, message: message) FileLogger.shared.write(.debug, category: category, message: message)
osLogger.debug("\(message, privacy: .public)") Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).debug("\(message, privacy: .public)") }
} }
func info(_ message: String) { nonisolated func info(_ message: String) {
FileLogger.shared.write(.info, category: category, message: message) FileLogger.shared.write(.info, category: category, message: message)
osLogger.info("\(message, privacy: .public)") Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).info("\(message, privacy: .public)") }
} }
func warning(_ message: String) { nonisolated func warning(_ message: String) {
FileLogger.shared.write(.warning, category: category, message: message) FileLogger.shared.write(.warning, category: category, message: message)
osLogger.warning("\(message, privacy: .public)") Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).warning("\(message, privacy: .public)") }
} }
func error(_ message: String) { nonisolated func error(_ message: String) {
FileLogger.shared.write(.error, category: category, message: message) FileLogger.shared.write(.error, category: category, message: message)
osLogger.error("\(message, privacy: .public)") Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") }
} }
} }
// MARK: - Log Namespace // MARK: - Log Namespace
enum Log { enum Log {
private static let subsystem = "com.oai.oAI" private nonisolated static let subsystem = "com.oai.oAI"
static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api") nonisolated static let api = AppLogger(subsystem: subsystem, category: "api")
static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database") nonisolated static let db = AppLogger(subsystem: subsystem, category: "database")
static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp") nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp")
static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings") nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings")
static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search") nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui") nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general") nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
} }
+110 -22
View File
@@ -360,7 +360,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
} }
func startAutoContinue() { func startAutoContinue() {
showSystemMessage("↩ Continuing…")
silentContinuePrompt = "Please continue from where you left off." silentContinuePrompt = "Please continue from where you left off."
Task { @MainActor in Task { @MainActor in
generateAIResponse(to: "", attachments: nil) generateAIResponse(to: "", attachments: nil)
@@ -799,8 +798,18 @@ Don't narrate future actions ("Let me...") - just use the tools.
let mcpActive = mcpEnabled || settings.mcpEnabled let mcpActive = mcpEnabled || settings.mcpEnabled
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
let bashActive = settings.bashEnabled let bashActive = settings.bashEnabled
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
let researchAgentsActive = settings.agentsEnabled
let externalMCPActive = !settings.externalMCPServers.filter { $0.isEnabled }.isEmpty
// Dedicated images API path (OpenRouter /images endpoint separate from chat completions)
if selectedModel?.capabilities.usesImagesAPI == true,
let orProvider = provider as? OpenRouterProvider {
generateImageAPIResponse(orProvider: orProvider, modelId: modelId, prompt: prompt)
return
}
let modelSupportTools = selectedModel?.capabilities.tools ?? false let modelSupportTools = selectedModel?.capabilities.tools ?? false
if modelSupportTools && (anytypeActive || bashActive || (mcpActive && !mcp.allowedFolders.isEmpty)) { if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || externalMCPActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
generateAIResponseWithTools(provider: provider, modelId: modelId) generateAIResponseWithTools(provider: provider, modelId: modelId)
return return
} }
@@ -934,10 +943,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages[index].tokens = usage.completionTokens messages[index].tokens = usage.completionTokens
if let model = selectedModel { if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
: nil
messages[index].cost = cost messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
} }
@@ -1001,10 +1007,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages[index].tokens = usage.completionTokens messages[index].tokens = usage.completionTokens
if let model = selectedModel { if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
: nil
messages[index].cost = cost messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
} }
@@ -1267,6 +1270,55 @@ Don't narrate future actions ("Let me...") - just use the tools.
// MARK: - AI Response with Tool Calls // MARK: - AI Response with Tool Calls
// MARK: - Images API Generation
private func generateImageAPIResponse(orProvider: OpenRouterProvider, modelId: String, prompt: String) {
isGenerating = true
streamingTask?.cancel()
streamingTask = Task {
let startTime = Date()
let assistantMessage = Message(
role: .assistant,
content: ThinkingVerbs.random(),
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil,
modelId: modelId,
isStreaming: true
)
let messageId = assistantMessage.id
messages.append(assistantMessage)
do {
let response = try await orProvider.generateImage(model: modelId, prompt: prompt)
let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = response.content
messages[index].isStreaming = false
messages[index].generatedImages = response.generatedImages
messages[index].responseTime = responseTime
if let usage = response.usage {
messages[index].tokens = usage.completionTokens
let cost = usage.rawCostUSD
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
_ = detectGoodbyePhrase(in: "")
} catch {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
messages[index].isStreaming = false
}
Log.api.error("Images API error: \(error)")
}
isGenerating = false
}
}
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) { private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
let mcp = MCPService.shared let mcp = MCPService.shared
Log.ui.info("generateAIResponseWithTools: model=\(modelId)") Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
@@ -1308,12 +1360,18 @@ Don't narrate future actions ("Let me...") - just use the tools.
systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.") systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.")
} }
let activeExternalServers = settings.externalMCPServers.filter { $0.isEnabled }
if !activeExternalServers.isEmpty {
let names = activeExternalServers.map { $0.name }.joined(separator: ", ")
systemParts.append("You have access to external tools from MCP servers: \(names). Their tools are prefixed with the server slug (e.g. safari_navigate_to_url). Use them proactively when the user's request relates to what those servers provide.")
}
var systemContent = systemParts.joined(separator: "\n\n") var systemContent = systemParts.joined(separator: "\n\n")
// Append the complete system prompt (default + custom) // Append the complete system prompt (default + custom)
systemContent += "\n\n---\n\n" + effectiveSystemPrompt systemContent += "\n\n---\n\n" + effectiveSystemPrompt
var messagesToSend: [Message] = memoryEnabled let messagesToSend: [Message] = memoryEnabled
? messages.filter { $0.role != .system } ? messages.filter { $0.role != .system }
: [messages.last(where: { $0.role == .user })].compactMap { $0 } : [messages.last(where: { $0.role == .user })].compactMap { $0 }
@@ -1360,6 +1418,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
var didContinueAfterImages = false // Only inject temp-file continuation once var didContinueAfterImages = false // Only inject temp-file continuation once
var totalUsage: ChatResponse.Usage? var totalUsage: ChatResponse.Usage?
var hitIterationLimit = false // Track if we exited due to hitting the limit 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 { for iteration in 0..<maxIterations {
if Task.isCancelled { if Task.isCancelled {
@@ -1412,6 +1471,12 @@ Don't narrate future actions ("Let me...") - just use the tools.
continue 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.
// Silently nudge a follow-up turn instead of showing a placeholder bubble.
finishedWithEmptyContent = true
}
break break
} }
@@ -1458,7 +1523,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
break 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 let resultJSON: String
if let data = try? JSONSerialization.data(withJSONObject: result), if let data = try? JSONSerialization.data(withJSONObject: result),
let str = String(data: data, encoding: .utf8) { let str = String(data: data, encoding: .utf8) {
@@ -1499,9 +1564,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// If this was the last iteration, note it // If this was the last iteration, note it
if iteration == maxIterations - 1 { if iteration == maxIterations - 1 {
hitIterationLimit = true // We're exiting with pending tool calls hitIterationLimit = true // We're exiting with pending tool calls
finalContent = response.content.isEmpty finalContent = response.content
? "[Tool loop reached maximum iterations]"
: response.content
} }
} }
@@ -1510,8 +1573,24 @@ Don't narrate future actions ("Let me...") - just use the tools.
wasCancelled = true wasCancelled = true
} }
// If we hit the iteration limit or the model returned no text at all, silently
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
// Display the final response as an assistant message // Display the final response as an assistant message
let responseTime = Date().timeIntervalSince(startTime) let responseTime = Date().timeIntervalSince(startTime)
if willAutoContinue && finalContent.isEmpty {
// Nothing worth showing yet still record usage/cost for this turn.
if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
sessionStats.addMessage(
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
cost: cost
)
}
} else {
let assistantMessage = Message( let assistantMessage = Message(
role: .assistant, role: .assistant,
content: finalContent, content: finalContent,
@@ -1529,10 +1608,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Calculate cost // Calculate cost
if let usage = totalUsage, let model = selectedModel { if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
: nil
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) { if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost messages[index].cost = cost
} }
@@ -1542,12 +1618,12 @@ Don't narrate future actions ("Let me...") - just use the tools.
cost: cost cost: cost
) )
} }
}
isGenerating = false isGenerating = false
streamingTask = nil streamingTask = nil
// If we hit the iteration limit and weren't cancelled, start auto-continue if willAutoContinue {
if hitIterationLimit && !wasCancelled {
startAutoContinue() startAutoContinue()
} }
@@ -1659,7 +1735,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
if isOverloaded && attempt < maxAttempts && !Task.isCancelled { if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
let delay = Double(1 << attempt) // 2s, 4s, 8s let delay = Double(1 << attempt) // 2s, 4s, 8s
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...") Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
await MainActor.run { _ = await MainActor.run {
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))") showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
} }
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
@@ -2180,6 +2256,18 @@ Don't narrate future actions ("Let me...") - just use the tools.
} }
} }
/// Cost for one response's usage, accounting for Anthropic-style prompt-cache
/// pricing when present: cache writes cost 1.25x the base input rate, cache
/// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder
/// it does not need cache tokens subtracted from it.
private func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000
let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000
let outputCost = Double(usage.completionTokens) * pricing.completion / 1_000_000
return inputCost + cacheReadCost + cacheWriteCost + outputCost
}
/// Summarize a chunk of messages into a concise summary /// Summarize a chunk of messages into a concise summary
private func summarizeMessageChunk(_ messages: [Message]) async -> String? { private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
guard let provider = providerRegistry.getProvider(for: currentProvider), guard let provider = providerRegistry.getProvider(for: currentProvider),
+22 -8
View File
@@ -37,12 +37,11 @@ struct ChatView: View {
HeaderView( HeaderView(
provider: viewModel.currentProvider, provider: viewModel.currentProvider,
model: viewModel.selectedModel, model: viewModel.selectedModel,
stats: viewModel.sessionStats,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled,
mcpStatus: viewModel.mcpStatus,
onModelSelect: onModelSelect, onModelSelect: onModelSelect,
onProviderChange: onProviderChange onProviderChange: onProviderChange,
conversationName: viewModel.currentConversationName,
hasUnsavedChanges: viewModel.hasUnsavedChanges,
onQuickSave: viewModel.quickSave
) )
// Messages // Messages
@@ -85,10 +84,13 @@ struct ChatView: View {
InputBar( InputBar(
text: $viewModel.inputText, text: $viewModel.inputText,
isGenerating: viewModel.isGenerating, isGenerating: viewModel.isGenerating,
mcpStatus: viewModel.mcpStatus,
onlineMode: viewModel.onlineMode, onlineMode: viewModel.onlineMode,
onSend: viewModel.sendMessage, onSend: viewModel.sendMessage,
onCancel: viewModel.cancelGeneration onCancel: viewModel.cancelGeneration,
onToggleOnline: {
viewModel.onlineMode.toggle()
SettingsService.shared.onlineMode = viewModel.onlineMode
}
) )
// Footer // Footer
@@ -96,7 +98,9 @@ struct ChatView: View {
stats: viewModel.sessionStats, stats: viewModel.sessionStats,
conversationName: viewModel.currentConversationName, conversationName: viewModel.currentConversationName,
hasUnsavedChanges: viewModel.hasUnsavedChanges, hasUnsavedChanges: viewModel.hasUnsavedChanges,
onQuickSave: viewModel.quickSave onQuickSave: viewModel.quickSave,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled
) )
} }
.background(Color.oaiBackground) .background(Color.oaiBackground)
@@ -119,6 +123,16 @@ struct ChatView: View {
onDeny: { MCPService.shared.denyPendingBashCommand() } 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() }
)
}
} }
} }
+53 -119
View File
@@ -2,7 +2,7 @@
// ContentView.swift // ContentView.swift
// oAI // oAI
// //
// Root navigation container // Root navigation container NavigationSplitView with collapsible sidebar
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen // Copyright (C) 2026 Rune Olsen
@@ -24,30 +24,34 @@
import SwiftUI import SwiftUI
#if os(macOS)
import Darwin // uname, sysctlbyname
#endif
struct ContentView: View { struct ContentView: View {
@Environment(ChatViewModel.self) var chatViewModel @Environment(ChatViewModel.self) var chatViewModel
private var updateService = UpdateCheckService.shared
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var showIntelWarning = false
var body: some View { var body: some View {
@Bindable var vm = chatViewModel @Bindable var vm = chatViewModel
NavigationStack { NavigationSplitView(columnVisibility: $columnVisibility) {
SidebarView()
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
} detail: {
ChatView( ChatView(
onModelSelect: { chatViewModel.showModelSelector = true }, onModelSelect: { chatViewModel.showModelSelector = true },
onProviderChange: { newProvider in onProviderChange: { newProvider in
chatViewModel.changeProvider(newProvider) chatViewModel.changeProvider(newProvider)
} }
) )
.navigationTitle("")
.toolbar {
#if os(macOS)
macOSToolbar
#endif
}
} }
.frame(minWidth: 860, minHeight: 560) .frame(minWidth: 860, minHeight: 560)
#if os(macOS) #if os(macOS)
.onAppear { .onAppear {
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed } NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
checkIntelWarning()
} }
.onKeyPress(.return, phases: .down) { press in .onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.command) { if press.modifiers.contains(.command) {
@@ -65,7 +69,6 @@ struct ContentView: View {
let oldModel = chatViewModel.selectedModel let oldModel = chatViewModel.selectedModel
chatViewModel.selectModel(model) chatViewModel.selectModel(model)
chatViewModel.showModelSelector = false chatViewModel.showModelSelector = false
// Trigger auto-save on model switch
Task { Task {
await chatViewModel.onModelSwitch(from: oldModel, to: model) await chatViewModel.onModelSwitch(from: oldModel, to: model)
} }
@@ -113,125 +116,56 @@ struct ContentView: View {
chatViewModel.inputText = input chatViewModel.inputText = input
}) })
} }
.alert("Intel Mac Support Ending", isPresented: $showIntelWarning) {
Button("Got It") {
UserDefaults.standard.set(true, forKey: "hasShownIntelWarning")
}
} message: {
Text("oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.")
}
.alert("Software Update", isPresented: Binding(
get: { updateService.manualCheckMessage != nil },
set: { if !$0 { updateService.manualCheckMessage = nil } }
)) {
if updateService.updateAvailable {
if let url = updateService.downloadURL {
Button("Download v\(updateService.latestVersion ?? "")") {
NSWorkspace.shared.open(url)
}
}
Button("Release Page") { updateService.openReleasesPage() }
Button("Later", role: .cancel) { }
} else {
Button("OK", role: .cancel) { }
}
} message: {
Text(updateService.manualCheckMessage ?? "")
}
} }
#if os(macOS) #if os(macOS)
@ToolbarContentBuilder private func checkIntelWarning() {
private var macOSToolbar: some ToolbarContent { guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
let settings = SettingsService.shared guard isIntelNative || isRosetta else { return }
let showLabels = settings.showToolbarLabels showIntelWarning = true
let iconSize = settings.toolbarIconSize
ToolbarItemGroup(placement: .automatic) {
// New conversation
Button(action: { chatViewModel.newConversation() }) {
ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, iconSize: iconSize)
} }
.keyboardShortcut("n", modifiers: .command)
.help("New conversation")
Button(action: { chatViewModel.showConversations = true }) { private var isIntelNative: Bool {
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize) var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) {
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
}
return machine.contains("x86_64")
} }
.keyboardShortcut("l", modifiers: .command)
.help("Saved conversations (Cmd+L)")
Button(action: { chatViewModel.showHistory = true }) { private var isRosetta: Bool {
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize) var ret: Int32 = 0
} var size = MemoryLayout<Int32>.size
.keyboardShortcut("h", modifiers: .command) sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
.help("Command history (Cmd+H)") return ret == 1
Spacer()
Button(action: { chatViewModel.showModelSelector = true }) {
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize)
}
.keyboardShortcut("m", modifiers: .command)
.help("Select AI model (Cmd+M)")
Button(action: {
if let model = chatViewModel.selectedModel {
chatViewModel.modelInfoTarget = model
}
}) {
ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, iconSize: iconSize)
}
.keyboardShortcut("i", modifiers: .command)
.help("Model info (Cmd+I)")
.disabled(chatViewModel.selectedModel == nil)
Button(action: { chatViewModel.showStats = true }) {
ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, iconSize: iconSize)
}
.help("Session statistics")
Button(action: { chatViewModel.showCredits = true }) {
ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, iconSize: iconSize)
}
.help("Check API credits")
Spacer()
Button(action: { chatViewModel.showSettings = true }) {
ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, iconSize: iconSize)
}
.keyboardShortcut(",", modifiers: .command)
.help("Settings (Cmd+,)")
Button(action: { chatViewModel.showHelp = true }) {
ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize)
}
.keyboardShortcut("/", modifiers: .command)
.help("Help & commands (Cmd+/)")
}
} }
#endif #endif
}
// Helper view for toolbar labels
struct ToolbarLabel: View {
let title: LocalizedStringKey
let systemImage: String
let showLabels: Bool
let iconSize: Double
// imageScale for the original range (32); explicit font size for the new extra-large range (>32)
private var scale: Image.Scale {
switch iconSize {
case ...18: return .small
case 19...24: return .medium
default: return .large
}
}
var body: some View {
if iconSize > 32 {
// Extra-large: explicit font size above the system .large ceiling
// Offset by 16 so slider 3418pt, 3620pt, 3822pt, 4024pt
if showLabels {
Label(title, systemImage: systemImage)
.labelStyle(.titleAndIcon)
.font(.system(size: iconSize - 16))
} else {
Label(title, systemImage: systemImage)
.labelStyle(.iconOnly)
.font(.system(size: iconSize - 16))
}
} else {
// Original behaviour imageScale keeps existing look intact
if showLabels {
Label(title, systemImage: systemImage)
.labelStyle(.titleAndIcon)
.imageScale(scale)
} else {
Label(title, systemImage: systemImage)
.labelStyle(.iconOnly)
.imageScale(scale)
}
}
}
} }
#Preview { #Preview {
+22 -20
View File
@@ -30,15 +30,22 @@ struct FooterView: View {
let conversationName: String? let conversationName: String?
let hasUnsavedChanges: Bool let hasUnsavedChanges: Bool
let onQuickSave: (() -> Void)? let onQuickSave: (() -> Void)?
let onlineMode: Bool
let mcpEnabled: Bool
private let settings = SettingsService.shared
init(stats: SessionStats, init(stats: SessionStats,
conversationName: String? = nil, conversationName: String? = nil,
hasUnsavedChanges: Bool = false, hasUnsavedChanges: Bool = false,
onQuickSave: (() -> Void)? = nil) { onQuickSave: (() -> Void)? = nil,
onlineMode: Bool = false,
mcpEnabled: Bool = false) {
self.stats = stats self.stats = stats
self.conversationName = conversationName self.conversationName = conversationName
self.hasUnsavedChanges = hasUnsavedChanges self.hasUnsavedChanges = hasUnsavedChanges
self.onQuickSave = onQuickSave self.onQuickSave = onQuickSave
self.onlineMode = onlineMode
self.mcpEnabled = mcpEnabled
} }
var body: some View { var body: some View {
@@ -71,26 +78,26 @@ struct FooterView: View {
Spacer() Spacer()
// Save indicator (only when chat has messages) // Status pills Online, MCP, Sync
if stats.messageCount > 0 { #if os(macOS)
SaveIndicator( HStack(spacing: 6) {
conversationName: conversationName, if onlineMode {
hasUnsavedChanges: hasUnsavedChanges, StatusPill(icon: "globe", label: "Online", color: .green)
onSave: onQuickSave
)
} }
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
if settings.syncEnabled && settings.syncAutoSave {
SyncStatusPill()
}
}
#endif
// Update available badge // Update available badge (shows only when an update exists no version number)
#if os(macOS) #if os(macOS)
UpdateBadge() UpdateBadge()
#endif #endif
// Shortcuts hint
#if os(macOS)
Text("⌘N New • ⌘M Model • ⌘S Save")
.font(.caption2)
.foregroundColor(.oaiSecondary)
#endif
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -242,7 +249,6 @@ struct SyncStatusFooter: View {
struct UpdateBadge: View { struct UpdateBadge: View {
private let updater = UpdateCheckService.shared private let updater = UpdateCheckService.shared
private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
var body: some View { var body: some View {
if updater.updateAvailable { if updater.updateAvailable {
@@ -258,10 +264,6 @@ struct UpdateBadge: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("A new version is available — click to open the releases page") .help("A new version is available — click to open the releases page")
} else {
Text("v\(currentVersion)")
.font(.caption2)
.foregroundColor(.oaiSecondary)
} }
} }
} }
+71 -102
View File
@@ -2,7 +2,8 @@
// HeaderView.swift // HeaderView.swift
// oAI // oAI
// //
// Header bar with provider, model, and stats // Slim header provider, model name, star only.
// Status pills and stats live in SidebarView and FooterView respectively.
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen // Copyright (C) 2026 Rune Olsen
@@ -28,19 +29,68 @@ import SwiftUI
struct HeaderView: View { struct HeaderView: View {
let provider: Settings.Provider let provider: Settings.Provider
let model: ModelInfo? let model: ModelInfo?
let stats: SessionStats
let onlineMode: Bool
let mcpEnabled: Bool
let mcpStatus: String?
let onModelSelect: () -> Void let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void let onProviderChange: (Settings.Provider) -> Void
var conversationName: String? = nil
var hasUnsavedChanges: Bool = false
var onQuickSave: (() -> Void)? = nil
private let settings = SettingsService.shared private let settings = SettingsService.shared
private let registry = ProviderRegistry.shared private let registry = ProviderRegistry.shared
private let gitSync = GitSyncService.shared
var body: some View { var body: some View {
HStack(spacing: 20) { ZStack {
// Provider picker dropdown only shows configured providers // Left: provider + model + star
HStack(spacing: 12) {
providerMenu
modelButton
starButton
Spacer()
}
// Center: conversation title (macOS document-title style)
conversationTitle
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .bottom
)
}
// MARK: - Conversation title (center)
@ViewBuilder
private var conversationTitle: some View {
if let name = conversationName {
Button(action: { if hasUnsavedChanges { onQuickSave?() } }) {
HStack(spacing: 5) {
if hasUnsavedChanges {
Circle()
.fill(Color.orange)
.frame(width: 6, height: 6)
}
Text(name)
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
.foregroundColor(.oaiPrimary)
.lineLimit(1)
.frame(maxWidth: 300)
}
}
.buttonStyle(.plain)
.disabled(!hasUnsavedChanges)
.help(hasUnsavedChanges ? "Unsaved changes — click to save" : "Saved")
.animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges)
.animation(.easeInOut(duration: 0.2), value: name)
}
}
// MARK: - Subviews (extracted so ZStack stays readable)
private var providerMenu: some View {
Menu { Menu {
ForEach(registry.configuredProviders, id: \.self) { p in ForEach(registry.configuredProviders, id: \.self) { p in
Button { Button {
@@ -49,9 +99,7 @@ struct HeaderView: View {
HStack { HStack {
Image(systemName: p.iconName) Image(systemName: p.iconName)
Text(p.displayName) Text(p.displayName)
if p == provider { if p == provider { Image(systemName: "checkmark") }
Image(systemName: "checkmark")
}
} }
} }
} }
@@ -74,58 +122,46 @@ struct HeaderView: View {
.menuStyle(.borderlessButton) .menuStyle(.borderlessButton)
.fixedSize() .fixedSize()
.help("Switch provider") .help("Switch provider")
}
// Model info (clickable model selector) private var modelButton: some View {
Button(action: onModelSelect) { Button(action: onModelSelect) {
if let model = model { if let model = model {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(model.name) Text(model.name)
.font(.system(size: settings.guiTextSize, weight: .medium)) .font(.system(size: settings.guiTextSize, weight: .medium))
.foregroundColor(.oaiPrimary) .foregroundColor(.oaiPrimary)
// Capability badges
HStack(spacing: 3) { HStack(spacing: 3) {
if model.capabilities.vision { if model.capabilities.vision {
Image(systemName: "eye") Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
if model.capabilities.tools { if model.capabilities.tools {
Image(systemName: "wrench") Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
if model.capabilities.online { if model.capabilities.online {
Image(systemName: "globe") Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
if model.capabilities.imageGeneration { if model.capabilities.imageGeneration {
Image(systemName: "paintbrush") Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
} }
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
} }
} else { } else {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("No model selected") Text("No model selected")
.font(.system(size: settings.guiTextSize)) .font(.system(size: settings.guiTextSize))
.foregroundColor(.oaiSecondary) .foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
} }
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Select model") .help("Select model")
}
@ViewBuilder
private var starButton: some View {
if let model = model { if let model = model {
let isFav = settings.favoriteModelIds.contains(model.id) let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) { Button(action: { settings.toggleFavoriteModel(model.id) }) {
@@ -136,68 +172,10 @@ struct HeaderView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.help(isFav ? "Remove from favorites" : "Add to favorites") .help(isFav ? "Remove from favorites" : "Add to favorites")
} }
Spacer()
// Status indicators
HStack(spacing: 8) {
if model?.capabilities.imageGeneration == true {
StatusPill(icon: "paintbrush", label: "Image", color: .purple)
}
if onlineMode {
StatusPill(icon: "globe", label: "Online", color: .green)
}
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
if settings.syncEnabled && settings.syncAutoSave {
SyncStatusPill()
}
}
// Divider between status and stats
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) {
Divider()
.frame(height: 16)
.opacity(0.5)
}
// Quick stats
HStack(spacing: 16) {
StatItem(icon: "message", value: "\(stats.messageCount)")
StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay)
StatItem(icon: "dollarsign", value: stats.totalCostDisplay)
}
.font(.caption)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .bottom
)
} }
} }
struct StatItem: View { // MARK: - Status Pills (used by SidebarView)
let icon: String
let value: String
private let settings = SettingsService.shared
var body: some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: settings.guiTextSize - 3))
.foregroundColor(.oaiSecondary)
Text(value)
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
.foregroundColor(.oaiPrimary)
}
}
}
struct StatusPill: View { struct StatusPill: View {
let icon: String let icon: String
@@ -284,15 +262,6 @@ struct SyncStatusPill: View {
HeaderView( HeaderView(
provider: .openrouter, provider: .openrouter,
model: ModelInfo.mockModels.first, model: ModelInfo.mockModels.first,
stats: SessionStats(
totalInputTokens: 125,
totalOutputTokens: 434,
totalCost: 0.00111,
messageCount: 4
),
onlineMode: true,
mcpEnabled: true,
mcpStatus: "MCP",
onModelSelect: {}, onModelSelect: {},
onProviderChange: { _ in } onProviderChange: { _ in }
) )
+125 -130
View File
@@ -2,7 +2,7 @@
// InputBar.swift // InputBar.swift
// oAI // oAI
// //
// Message input bar with status indicators // Message input bar with resizable height and online toggle
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen // Copyright (C) 2026 Rune Olsen
@@ -24,19 +24,30 @@
import SwiftUI import SwiftUI
#if os(macOS)
import AppKit
#endif
struct InputBar: View { struct InputBar: View {
@Binding var text: String @Binding var text: String
let isGenerating: Bool let isGenerating: Bool
let mcpStatus: String?
let onlineMode: Bool let onlineMode: Bool
let onSend: () -> Void let onSend: () -> Void
let onCancel: () -> Void let onCancel: () -> Void
let onToggleOnline: () -> Void
private let settings = SettingsService.shared private let settings = SettingsService.shared
// Resizable input height persisted to settings
@State private var inputHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
@State private var dragStartHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
@State private var showCommandDropdown = false @State private var showCommandDropdown = false
@State private var selectedSuggestionIndex: Int = 0 @State private var selectedSuggestionIndex: Int = 0
@FocusState private var isInputFocused: Bool @State private var isInputFocused: Bool = false
private static let minInputHeight: CGFloat = 56
private static let maxInputHeight: CGFloat = 320
/// Commands that execute immediately without additional arguments /// Commands that execute immediately without additional arguments
private static let immediateCommands: Set<String> = [ private static let immediateCommands: Set<String> = [
@@ -56,110 +67,98 @@ struct InputBar: View {
CommandSuggestionsView( CommandSuggestionsView(
searchText: text, searchText: text,
selectedIndex: selectedSuggestionIndex, selectedIndex: selectedSuggestionIndex,
onSelect: { command in onSelect: selectCommand
selectCommand(command)
}
) )
.frame(width: 400) .frame(width: 400)
.frame(maxHeight: 200) .frame(maxHeight: 200)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
Spacer() Spacer()
} }
.padding(.leading, 96) // Align with input box (status badges + spacing) .padding(.leading, 16)
} }
// Input area // Drag-to-resize handle
dragHandle
// Input row
HStack(alignment: .bottom, spacing: 12) { HStack(alignment: .bottom, spacing: 12) {
// Status indicators // Text input with globe toggle in bottom-left corner
HStack(spacing: 6) {
if let mcp = mcpStatus {
StatusBadge(text: mcp, color: .blue)
}
if onlineMode {
StatusBadge(text: "🌐", color: .green)
}
}
.frame(width: 80, alignment: .leading)
// Text input
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
// Placeholder
if text.isEmpty { if text.isEmpty {
Text("Type a message or / for commands...") Text("Type a message or / for commands...")
.font(.system(size: settings.inputTextSize)) .font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiSecondary) .foregroundColor(.oaiSecondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.top, 10)
.allowsHitTesting(false)
} }
TextEditor(text: $text) // Editor fills the fixed-height box, bottom area reserved for globe
.font(.system(size: settings.inputTextSize)) NativeTextEditor(
.foregroundColor(.oaiPrimary) text: $text,
.scrollContentBackground(.hidden) font: .systemFont(ofSize: settings.inputTextSize),
.frame(minHeight: 44, maxHeight: 120) textColor: NSColor(Color.oaiPrimary),
.padding(.horizontal, 8) isFocused: isInputFocused,
.padding(.vertical, 6) onReturn: {
.focused($isInputFocused)
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
selectedSuggestionIndex = 0
}
#if os(macOS)
.onKeyPress(.upArrow) {
// Navigate command dropdown
if showCommandDropdown && selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
return .handled
}
return .ignored
}
.onKeyPress(.downArrow) {
// Navigate command dropdown
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
return .handled
}
}
return .ignored
}
.onKeyPress(.escape) {
// If command dropdown is showing, close it
if showCommandDropdown {
showCommandDropdown = false
return .handled
}
// If model is generating, cancel it
if isGenerating {
onCancel()
return .handled
}
return .ignored
}
.onKeyPress(.return, phases: .down) { press in
// Shift+Return: always insert newline (let system handle)
if press.modifiers.contains(.shift) {
return .ignored
}
// If command dropdown is showing, select the highlighted command
if showCommandDropdown { if showCommandDropdown {
let suggestions = CommandSuggestionsView.filteredCommands(for: text) let suggestions = CommandSuggestionsView.filteredCommands(for: text)
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count { if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
selectCommand(suggestions[selectedSuggestionIndex].command) selectCommand(suggestions[selectedSuggestionIndex].command)
return .handled return true
} }
} }
// Return (plain or with Cmd): send message if !text.isEmpty { onSend(); return true }
if !text.isEmpty { return true
onSend() },
return .handled onEscape: {
if showCommandDropdown { showCommandDropdown = false; return true }
if isGenerating { onCancel(); return true }
return false
},
onUpArrow: {
if showCommandDropdown && selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1; return true
} }
// Empty text: do nothing return false
return .handled },
onDownArrow: {
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1; return true
} }
#endif
} }
return false
},
onFocusChange: { focused in isInputFocused = focused }
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
selectedSuggestionIndex = 0
}
.padding(.bottom, 30)
// Online / offline toggle bottom-left of the text box
VStack {
Spacer()
HStack {
Button(action: onToggleOnline) {
Image(systemName: onlineMode ? "globe" : "network.slash")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(onlineMode ? Color.green : Color.secondary)
.padding(8)
}
.buttonStyle(.plain)
.help(onlineMode
? "Online mode on — click to go offline"
: "Offline — click to go online")
Spacer()
}
}
}
.frame(height: inputHeight)
.background(Color.oaiSurface) .background(Color.oaiSurface)
.cornerRadius(8) .cornerRadius(8)
.overlay( .overlay(
@@ -167,10 +166,9 @@ struct InputBar: View {
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1) .stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
) )
// Action buttons // Send / stop + attach buttons
VStack(spacing: 8) { VStack(spacing: 8) {
#if os(macOS) #if os(macOS)
// File attach button
Button(action: pickFile) { Button(action: pickFile) {
Image(systemName: "paperclip") Image(systemName: "paperclip")
.font(.title2) .font(.title2)
@@ -209,21 +207,47 @@ struct InputBar: View {
} }
} }
// MARK: - Drag handle
private var dragHandle: some View {
Color.clear
.frame(height: 8)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.overlay {
Capsule()
.fill(Color.secondary.opacity(0.25))
.frame(width: 36, height: 3)
}
.gesture(
DragGesture(minimumDistance: 1)
.onChanged { value in
let proposed = dragStartHeight - value.translation.height
inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed))
}
.onEnded { _ in
dragStartHeight = inputHeight
settings.inputBarHeight = Double(inputHeight)
}
)
#if os(macOS)
.onHover { hovering in
if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
}
#endif
}
// MARK: - Helpers
private func selectCommand(_ command: String) { private func selectCommand(_ command: String) {
showCommandDropdown = false showCommandDropdown = false
if Self.immediateCommands.contains(command) { if Self.immediateCommands.contains(command) {
// Execute immediately
text = command text = command
onSend() onSend()
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) { } else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
if shortcut.needsInput { text = shortcut.needsInput ? command + " " : command
text = command + " " if !shortcut.needsInput { onSend() }
} else { } else {
text = command
onSend()
}
} else {
// Put in input for user to complete
text = command + " " text = command + " "
} }
} }
@@ -235,36 +259,14 @@ struct InputBar: View {
panel.canChooseDirectories = false panel.canChooseDirectories = false
panel.canChooseFiles = true panel.canChooseFiles = true
panel.message = "Select files to attach" panel.message = "Select files to attach"
guard panel.runModal() == .OK else { return } guard panel.runModal() == .OK else { return }
let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ")
let paths = panel.urls.map { $0.path } text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText
// Use @<path> format (angle brackets) to safely handle paths with spaces
let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ")
if text.isEmpty {
text = attachmentText + " "
} else {
text += " " + attachmentText
}
} }
#endif #endif
} }
struct StatusBadge: View { // MARK: - Command suggestions
let text: String
let color: Color
var body: some View {
Text(text)
.font(.caption)
.foregroundColor(color)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.cornerRadius(4)
}
}
struct CommandSuggestionsView: View { struct CommandSuggestionsView: View {
let searchText: String let searchText: String
@@ -304,10 +306,9 @@ struct CommandSuggestionsView: View {
] ]
static func allCommands() -> [(command: String, description: LocalizedStringKey)] { static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
let shortcuts = SettingsService.shared.userShortcuts.map { s in SettingsService.shared.userShortcuts.map { s in
(s.command, LocalizedStringKey("\(s.description)")) (s.command, LocalizedStringKey("\(s.description)"))
} } + builtInCommands
return builtInCommands + shortcuts
} }
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] { static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
@@ -344,26 +345,20 @@ struct CommandSuggestionsView: View {
.id(suggestion.command) .id(suggestion.command)
if index < suggestions.count - 1 { if index < suggestions.count - 1 {
Divider() Divider().background(Color.oaiBorder)
.background(Color.oaiBorder)
} }
} }
} }
} }
.onChange(of: selectedIndex) { .onChange(of: selectedIndex) {
if selectedIndex < suggestions.count { if selectedIndex < suggestions.count {
withAnimation { withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) }
proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
}
} }
} }
} }
.background(Color.oaiSurface) .background(Color.oaiSurface)
.cornerRadius(8) .cornerRadius(8)
.overlay( .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1))
RoundedRectangle(cornerRadius: 8)
.stroke(Color.oaiBorder, lineWidth: 1)
)
} }
} }
@@ -373,10 +368,10 @@ struct CommandSuggestionsView: View {
InputBar( InputBar(
text: .constant(""), text: .constant(""),
isGenerating: false, isGenerating: false,
mcpStatus: "📁 Files",
onlineMode: true, onlineMode: true,
onSend: {}, onSend: {},
onCancel: {} onCancel: {},
onToggleOnline: {}
) )
} }
.background(Color.oaiBackground) .background(Color.oaiBackground)
+7
View File
@@ -61,8 +61,15 @@ struct MarkdownContentView: View {
.markdownBlockStyle(\.paragraph) { configuration in .markdownBlockStyle(\.paragraph) { configuration in
configuration.label configuration.label
.markdownMargin(top: 0, bottom: 8) .markdownMargin(top: 0, bottom: 8)
// MarkdownUI builds mixed-style paragraphs (bold/italic runs alongside
// plain text) as concatenated Text(+) segments, which on macOS report
// their ideal (unwrapped, single-line) size instead of wrapping to the
// width actually available truncating with "" mid-word. Forcing the
// height to be recomputed for the given (flexible) width fixes it.
.fixedSize(horizontal: false, vertical: true)
} }
.textSelection(.enabled) .textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
} }
// MARK: - Parsing // MARK: - Parsing
+1
View File
@@ -185,6 +185,7 @@ struct MessageRow: View {
.foregroundColor(.oaiSecondary) .foregroundColor(.oaiSecondary)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
.padding(16) .padding(16)
.background(Color.messageBackground(for: message.role)) .background(Color.messageBackground(for: message.role))
+171
View File
@@ -0,0 +1,171 @@
//
// NativeTextEditor.swift
// oAI
//
// NSViewRepresentable text editor with correct Enter-key semantics:
// plain Enter send, Shift+Enter or Cmd+Enter newline.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import SwiftUI
import AppKit
struct NativeTextEditor: NSViewRepresentable {
@Binding var text: String
var font: NSFont
var textColor: NSColor
var isFocused: Bool
/// Plain Enter (no modifiers). Return true if the event was consumed.
var onReturn: () -> Bool
/// Escape key. Return true if consumed.
var onEscape: () -> Bool
/// Up arrow. Return true if consumed.
var onUpArrow: () -> Bool
/// Down arrow. Return true if consumed.
var onDownArrow: () -> Bool
/// Called when the view gains or loses first-responder status.
var onFocusChange: (Bool) -> Void
// MARK: - NSViewRepresentable
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = false
scrollView.hasHorizontalScroller = false
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
let tv = context.coordinator.textView
tv.delegate = context.coordinator
tv.isEditable = true
tv.isRichText = false
tv.drawsBackground = false
tv.backgroundColor = .clear
tv.isAutomaticQuoteSubstitutionEnabled = false
tv.isAutomaticDashSubstitutionEnabled = false
tv.isAutomaticSpellingCorrectionEnabled = true
tv.isContinuousSpellCheckingEnabled = true
tv.allowsUndo = true
tv.isVerticallyResizable = true
tv.isHorizontallyResizable = false
tv.autoresizingMask = [.width]
tv.textContainer?.widthTracksTextView = true
tv.textContainerInset = NSSize(width: 8, height: 6)
scrollView.documentView = tv
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
let tv = context.coordinator.textView
let coord = context.coordinator
// Update text only when it differs (avoids caret-jumping on every keystroke)
if tv.string != text {
let sel = tv.selectedRanges
tv.string = text
let len = (tv.string as NSString).length
tv.selectedRanges = sel.map { v in
let r = v.rangeValue
let loc = min(r.location, len)
let length = min(r.length, max(0, len - loc))
return NSValue(range: NSRange(location: loc, length: length))
}
}
if tv.font != font { tv.font = font }
if tv.textColor != textColor { tv.textColor = textColor }
// Keep coordinator callbacks current with each SwiftUI render
coord.textBinding = $text
coord.onReturn = onReturn
coord.onEscape = onEscape
coord.onUpArrow = onUpArrow
coord.onDownArrow = onDownArrow
coord.onFocusChange = onFocusChange
if isFocused {
DispatchQueue.main.async {
guard let window = tv.window, window.firstResponder !== tv else { return }
window.makeFirstResponder(tv)
}
}
}
func makeCoordinator() -> Coordinator { Coordinator() }
// MARK: - Coordinator
final class Coordinator: NSObject, NSTextViewDelegate {
let textView = KeyableNSTextView()
// Updated on every SwiftUI render via updateNSView
var textBinding: Binding<String>?
var onReturn: () -> Bool = { false }
var onEscape: () -> Bool = { false }
var onUpArrow: () -> Bool = { false }
var onDownArrow: () -> Bool = { false }
var onFocusChange: (Bool) -> Void = { _ in }
override init() {
super.init()
textView.coordinator = self
}
func textDidChange(_ notification: Notification) {
guard let tv = notification.object as? NSTextView else { return }
textBinding?.wrappedValue = tv.string
}
}
}
// MARK: - KeyableNSTextView
/// NSTextView that routes Return / Escape / arrow keys to the SwiftUI
/// coordinator before the AppKit default handling runs.
final class KeyableNSTextView: NSTextView {
weak var coordinator: NativeTextEditor.Coordinator?
override func keyDown(with event: NSEvent) {
guard let coord = coordinator else { super.keyDown(with: event); return }
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let shift = flags.contains(.shift)
let cmd = flags.contains(.command)
switch event.keyCode {
case 36: // Return
if shift || cmd {
// Shift+Enter or Cmd+Enter literal newline
insertNewlineIgnoringFieldEditor(nil)
} else {
// Plain Enter let SwiftUI decide (send or select dropdown item)
if !coord.onReturn() {
insertNewlineIgnoringFieldEditor(nil)
}
}
case 53: // Escape
if !coord.onEscape() { super.keyDown(with: event) }
case 126: // Up arrow
if !coord.onUpArrow() { super.keyDown(with: event) }
case 125: // Down arrow
if !coord.onDownArrow() { super.keyDown(with: event) }
default:
super.keyDown(with: event)
}
}
override func becomeFirstResponder() -> Bool {
let ok = super.becomeFirstResponder()
if ok { coordinator?.onFocusChange(true) }
return ok
}
override func resignFirstResponder() -> Bool {
let ok = super.resignFirstResponder()
if ok { coordinator?.onFocusChange(false) }
return ok
}
}
+216
View File
@@ -0,0 +1,216 @@
//
// SidebarView.swift
// oAI
//
// Collapsible sidebar: new chat, conversation list, status pills
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import SwiftUI
#if os(macOS)
import AppKit
#endif
struct SidebarView: View {
@Environment(ChatViewModel.self) private var chatViewModel
@State private var conversations: [Conversation] = []
@State private var searchText = ""
private var filteredConversations: [Conversation] {
guard !searchText.isEmpty else { return conversations }
return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) }
}
var body: some View {
VStack(spacing: 0) {
// New Chat button
Button(action: { chatViewModel.newConversation() }) {
HStack(spacing: 8) {
Image(systemName: "square.and.pencil")
.font(.system(size: 14))
Text("New Chat")
.font(.system(size: 14, weight: .medium))
Spacer()
}
.foregroundColor(.oaiPrimary)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Search field
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.system(size: 12))
.foregroundStyle(.secondary)
TextField("Search conversations…", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 13))
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
Divider().frame(height: 12)
Button {
chatViewModel.showConversations = true
} label: {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Advanced search — semantic search, bulk delete, export")
}
.padding(7)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
.padding(.horizontal, 8)
.padding(.bottom, 6)
Divider()
// Conversation list
if filteredConversations.isEmpty {
Spacer()
VStack(spacing: 8) {
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
.font(.title2)
.foregroundStyle(.tertiary)
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
.font(.callout)
.foregroundStyle(.secondary)
}
Spacer()
} else {
List {
ForEach(filteredConversations) { conversation in
SidebarConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
chatViewModel.loadConversation(conversation)
}
.listRowBackground(
chatViewModel.currentConversationName == conversation.name
? Color.oaiAccent.opacity(0.15)
: Color.clear
)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
deleteConversation(conversation)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
renameConversation(conversation)
} label: {
Label("Rename", systemImage: "pencil")
}
.tint(.orange)
}
}
}
.listStyle(.sidebar)
}
}
.onAppear { loadConversations() }
.onChange(of: chatViewModel.currentConversationName) { loadConversations() }
.onChange(of: chatViewModel.messages.count) { loadConversations() }
}
private func loadConversations() {
conversations = (try? DatabaseService.shared.listConversations()) ?? []
}
private func deleteConversation(_ conversation: Conversation) {
_ = try? DatabaseService.shared.deleteConversation(id: conversation.id)
withAnimation {
conversations.removeAll { $0.id == conversation.id }
}
}
private func renameConversation(_ conversation: Conversation) {
#if os(macOS)
let alert = NSAlert()
alert.messageText = "Rename Conversation"
alert.addButton(withTitle: "Rename")
alert.addButton(withTitle: "Cancel")
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
input.stringValue = conversation.name
input.selectText(nil)
alert.accessoryView = input
alert.window.initialFirstResponder = input
guard alert.runModal() == .alertFirstButtonReturn else { return }
let newName = input.stringValue.trimmingCharacters(in: .whitespaces)
guard !newName.isEmpty, newName != conversation.name else { return }
do {
_ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil)
if let i = conversations.firstIndex(where: { $0.id == conversation.id }) {
conversations[i].name = newName
conversations[i].updatedAt = Date()
}
chatViewModel.didRenameConversation(id: conversation.id, newName: newName)
} catch {
Log.db.error("Failed to rename conversation: \(error.localizedDescription)")
}
#endif
}
}
// MARK: - Sidebar conversation row
struct SidebarConversationRow: View {
let conversation: Conversation
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "dd.MM.yyyy"
return formatter.string(from: conversation.updatedAt)
}
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(conversation.name)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
HStack(spacing: 4) {
Text("^[\(conversation.messageCount) message](inflect: true)")
.font(.system(size: 11))
Text("·")
.font(.system(size: 11))
Text(formattedDate)
.font(.system(size: 11))
}
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
#Preview {
SidebarView()
.environment(ChatViewModel())
.frame(width: 240, height: 600)
}
+16 -2
View File
@@ -113,7 +113,9 @@ struct AgentSkillsView: View {
onToggle: { settings.toggleAgentSkill(id: skill.id) }, onToggle: { settings.toggleAgentSkill(id: skill.id) },
onEdit: { editContext = SkillEditContext(skill: skill) }, onEdit: { editContext = SkillEditContext(skill: skill) },
onExport: { exportOne(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 onEdit: () -> Void
let onExport: () -> Void let onExport: () -> Void
let onDelete: () -> Void let onDelete: () -> Void
var isTrusted: Bool = false
var onToggleTrust: (() -> Void)? = nil
private var fileCount: Int { private var fileCount: Int {
AgentSkillFilesService.shared.listFiles(for: skill.id).count AgentSkillFilesService.shared.listFiles(for: skill.id).count
@@ -378,6 +382,14 @@ private struct AgentSkillRow: View {
Spacer() 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 // File count badge
if fileCount > 0 { if fileCount > 0 {
Label("^[\(fileCount) file](inflect: true)", systemImage: "doc") Label("^[\(fileCount) file](inflect: true)", systemImage: "doc")
@@ -479,7 +491,9 @@ struct AgentSkillsTabContent: View {
onToggle: { settings.toggleAgentSkill(id: skill.id) }, onToggle: { settings.toggleAgentSkill(id: skill.id) },
onEdit: { editContext = SkillEditContext(skill: skill) }, onEdit: { editContext = SkillEditContext(skill: skill) },
onExport: { exportOne(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() } 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 semanticResults: [Conversation] = []
@State private var isSearching = false @State private var isSearching = false
@State private var selectedIndex: Int = 0 @State private var selectedIndex: Int = 0
@State private var showCombineSheet = false
@FocusState private var searchFocused: Bool @FocusState private var searchFocused: Bool
private let settings = SettingsService.shared private let settings = SettingsService.shared
var onLoad: ((Conversation) -> Void)? var onLoad: ((Conversation) -> Void)?
@@ -70,6 +71,18 @@ struct ConversationListView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
if selectedConversations.count >= 2 {
Button {
showCombineSheet = true
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.merge")
Text("Combine (\(selectedConversations.count))")
}
}
.buttonStyle(.plain)
}
if !selectedConversations.isEmpty { if !selectedConversations.isEmpty {
Button(role: .destructive) { Button(role: .destructive) {
deleteSelected() deleteSelected()
@@ -298,6 +311,16 @@ struct ConversationListView: View {
searchFocused = true searchFocused = true
} }
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600) .frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
.sheet(isPresented: $showCombineSheet) {
CombineConversationsSheet(
conversations: conversations.filter { selectedConversations.contains($0.id) },
onCompleted: { _ in
loadConversations()
selectedConversations.removeAll()
isSelecting = false
}
)
}
} }
private func loadConversations() { private func loadConversations() {
+1 -1
View File
@@ -52,7 +52,7 @@ private let helpCategories: [CommandCategory] = [
brief: "View command history", brief: "View command history",
detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.", detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.",
examples: ["/history"], examples: ["/history"],
shortcut: "⌘H" shortcut: "⌘H"
), ),
CommandDetail( CommandDetail(
command: "/clear", command: "/clear",
+12 -1
View File
@@ -30,6 +30,7 @@ struct ModelInfoView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Bindable private var settings = SettingsService.shared @Bindable private var settings = SettingsService.shared
@State private var isDescriptionExpanded = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -78,8 +79,18 @@ struct ModelInfoView: View {
Text(desc) Text(desc)
.font(.body) .font(.body)
.foregroundColor(.primary) .foregroundColor(.primary)
.fixedSize(horizontal: false, vertical: true) .lineLimit(isDescriptionExpanded ? nil : 4)
.textSelection(.enabled) .textSelection(.enabled)
if desc.count > 250 {
Button(isDescriptionExpanded ? "Less" : "More…") {
withAnimation(.easeInOut(duration: 0.2)) {
isDescriptionExpanded.toggle()
}
}
.font(.callout)
.foregroundStyle(.blue)
.buttonStyle(.plain)
}
} }
.padding(.leading, 4) .padding(.leading, 4)
} }
@@ -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)
}
}
+426
View File
@@ -75,6 +75,20 @@ struct SettingsView: View {
// Default model picker state // Default model picker state
@State private var showDefaultModelPicker = false @State private var showDefaultModelPicker = false
// External MCP Servers state
@State private var showAddExternalMCPServer = false
@State private var newMCPServerName = ""
@State private var newMCPServerCommand = ""
@State private var newMCPServerArgs = ""
@State private var newMCPServerTimeout: Double = 30
private var externalMCPManager = ExternalMCPManager.shared
// 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 // Paperless-NGX state
@State private var paperlessURL = "" @State private var paperlessURL = ""
@State private var paperlessToken = "" @State private var paperlessToken = ""
@@ -752,6 +766,367 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.padding(.horizontal, 4) .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: External MCP Servers
Divider()
externalMCPSection
// 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: - External MCP Servers Section
@ViewBuilder
private var externalMCPSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "server.rack")
.font(.title2)
.foregroundStyle(.purple)
Text("External MCP Servers")
.font(.system(size: 18, weight: .semibold))
}
Text("Connect any stdio MCP server (e.g. safaridriver --mcp) to give the AI access to its tools. Tool names are prefixed with the server slug.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Configured Servers")
formSection {
if settingsService.externalMCPServers.isEmpty {
VStack(spacing: 8) {
Image(systemName: "server.rack")
.font(.system(size: 32))
.foregroundStyle(.tertiary)
Text("No external servers configured")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
Text("Add a server below, e.g.: safaridriver --mcp")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
ForEach(settingsService.externalMCPServers) { server in
HStack(spacing: 10) {
Circle()
.fill(mcpStatusColor(externalMCPManager.clientStates[server.id]))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(server.name)
.font(.system(size: 14))
Text(([server.command] + server.args).joined(separator: " "))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Text(mcpStatusLabel(externalMCPManager.clientStates[server.id]))
.font(.system(size: 11))
.foregroundStyle(.secondary)
Toggle("", isOn: Binding(
get: { server.isEnabled },
set: { _ in settingsService.toggleExternalMCPServer(id: server.id) }
))
.toggleStyle(.switch)
.labelsHidden()
Button {
settingsService.deleteExternalMCPServer(id: server.id)
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.system(size: 13))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
if server.id != settingsService.externalMCPServers.last?.id {
rowDivider()
}
}
}
}
Button {
newMCPServerName = ""
newMCPServerCommand = ""
newMCPServerArgs = ""
newMCPServerTimeout = 30
showAddExternalMCPServer = true
} label: {
Label("Add Server…", systemImage: "plus")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Color.purple)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
.sheet(isPresented: $showAddExternalMCPServer) {
addExternalMCPServerSheet
}
}
@ViewBuilder
private var addExternalMCPServerSheet: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Add External MCP Server")
.font(.system(size: 16, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .center)
formSection {
row("Name") {
TextField("Safari", text: $newMCPServerName)
.textFieldStyle(.roundedBorder)
.frame(width: 240)
}
rowDivider()
row("Command") {
TextField("safaridriver", text: $newMCPServerCommand)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
rowDivider()
row("Arguments") {
TextField("--mcp", text: $newMCPServerArgs)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
.help("Space-separated arguments")
}
rowDivider()
row("Timeout") {
HStack(spacing: 8) {
Stepper("", value: $newMCPServerTimeout, in: 5...120, step: 5)
.labelsHidden()
Text("\(Int(newMCPServerTimeout))s")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 32, alignment: .leading)
}
}
}
if !newMCPServerName.isEmpty {
let slug = ExternalMCPServer.makeSlug(from: newMCPServerName)
HStack(spacing: 6) {
Text("Tool prefix:")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text("\(slug)_")
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
}
if ExternalMCPServer.reservedSlugs.contains(slug) {
Text("'\(slug)' is a reserved prefix. Choose a different name.")
.font(.system(size: 12))
.foregroundStyle(.red)
}
}
HStack {
Button("Cancel") { showAddExternalMCPServer = false }
Spacer()
Button("Add") {
let args = ExternalMCPServer.parseArguments(newMCPServerArgs)
let server = ExternalMCPServer(
name: newMCPServerName,
command: newMCPServerCommand,
args: args,
timeout: newMCPServerTimeout
)
settingsService.addExternalMCPServer(server)
showAddExternalMCPServer = false
}
.buttonStyle(.borderedProminent)
.disabled(newMCPServerName.isEmpty || newMCPServerCommand.isEmpty ||
ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)))
}
}
.padding(24)
.frame(minWidth: 460, minHeight: 320)
}
private func mcpStatusColor(_ state: MCPClientState?) -> Color {
switch state {
case .ready: return .green
case .connecting: return .orange
case .error, .crashed: return .red
default: return Color(nsColor: .tertiaryLabelColor)
}
}
private func mcpStatusLabel(_ state: MCPClientState?) -> LocalizedStringKey {
switch state {
case .ready: return "Connected"
case .connecting: return "Connecting…"
case .error: return "Error"
case .crashed: return "Crashed"
default: return "Not started"
}
} }
// MARK: - Appearance Tab // MARK: - Appearance Tab
@@ -2446,6 +2821,57 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Divider().padding(.leading, 16) 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 { private func abbreviatePath(_ path: String) -> String {
let home = NSHomeDirectory() let home = NSHomeDirectory()
if path.hasPrefix(home) { if path.hasPrefix(home) {
+48
View File
@@ -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" "version" : "1.1"
+8
View File
@@ -8,5 +8,13 @@
<true/> <true/>
<key>com.apple.security.network.server</key> <key>com.apple.security.network.server</key>
<false/> <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> </dict>
</plist> </plist>
+40 -1
View File
@@ -37,6 +37,9 @@ struct oAIApp: App {
// Start email handler on app launch // Start email handler on app launch
EmailHandlerService.shared.start() EmailHandlerService.shared.start()
// Start external MCP servers
Task { @MainActor in ExternalMCPManager.shared.startAll() }
// Sync Git changes on app launch (pull + import) // Sync Git changes on app launch (pull + import)
Task { Task {
await GitSyncService.shared.syncOnStartup() await GitSyncService.shared.syncOnStartup()
@@ -56,7 +59,7 @@ struct oAIApp: App {
} }
#if os(macOS) #if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
// Trigger auto-save on app quit Task { @MainActor in ExternalMCPManager.shared.stopAll() }
Task { Task {
await chatViewModel.onAppWillTerminate() await chatViewModel.onAppWillTerminate()
} }
@@ -92,6 +95,8 @@ struct oAIApp: App {
CommandGroup(after: .newItem) { CommandGroup(after: .newItem) {
Button("Open Chat…") { chatViewModel.showConversations = true } Button("Open Chat…") { chatViewModel.showConversations = true }
.keyboardShortcut("o", modifiers: .command) .keyboardShortcut("o", modifiers: .command)
Button("Search Conversations") { chatViewModel.showConversations = true }
.keyboardShortcut("l", modifiers: .command)
} }
CommandGroup(replacing: .saveItem) { CommandGroup(replacing: .saveItem) {
@@ -113,10 +118,44 @@ struct oAIApp: App {
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
} }
// View menu
CommandMenu("View") {
Button("Select Model") { chatViewModel.showModelSelector = true }
.keyboardShortcut("m", modifiers: .command)
Button("Model Info") {
chatViewModel.modelInfoTarget = chatViewModel.selectedModel
}
.keyboardShortcut("i", modifiers: .command)
.disabled(chatViewModel.selectedModel == nil)
Divider()
Button("Command History") { chatViewModel.showHistory = true }
.keyboardShortcut("h", modifiers: [.command, .shift])
Button("In-App Help") { chatViewModel.showHelp = true }
.keyboardShortcut("/", modifiers: .command)
Button("Credits") { chatViewModel.showCredits = true }
Divider()
Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") {
chatViewModel.onlineMode.toggle()
}
.keyboardShortcut("o", modifiers: [.command, .shift])
}
// Help menu // Help menu
CommandGroup(replacing: .help) { CommandGroup(replacing: .help) {
Button("oAI Help") { openHelp() } Button("oAI Help") { openHelp() }
.keyboardShortcut("?", modifiers: .command) .keyboardShortcut("?", modifiers: .command)
Divider()
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
UpdateCheckService.shared.checkForUpdatesManually()
}
.disabled(UpdateCheckService.shared.isCheckingManually)
} }
} }
#endif #endif