14 Commits

Author SHA1 Message Date
rune 51b7d4ab0a Add Localizable strings for Apple Intelligence settings
Auto-generated by Xcode string catalog compiler when building
the Apple Intelligence section in SettingsView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:50:22 +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
rune cd0ceeab41 Merge pull request 'New release v2.3.9' (#5) from 2.3.9 into main
Reviewed-on: #5
2026-05-12 11:13:11 +02:00
rune 13699864d8 New release v2.3.9
- Jarvis integration: manage oAI-Web agents and usage from inside the app (/jarvis command, Settings tab 11)
- Model category filter: keyword-based categorisation with popover picker in model selector
- Categories shown in ModelInfoView with coloured chips; dot indicators on model rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:05:47 +02:00
rune c2010e272e Updated README.md 2026-04-15 09:00:40 +02:00
rune 098c3c3d1e Update README.md 2026-04-15 07:25:18 +02:00
rune 3d6ac578db Update README.md 2026-04-15 07:24:48 +02:00
rune 0f9dc05774 Merge pull request 'New release v2.3.8' (#4) from 2.3.8 into main
Reviewed-on: #4
2026-03-05 13:18:24 +01:00
rune 3f9b30bfa1 New release v2.3.8 2026-03-05 13:17:53 +01:00
rune 375b8fb345 Merge pull request 'Bugfix: Lingering error in image generation from image gen. models' (#3) from 2.3.7 into main
Reviewed-on: #3
2026-03-04 12:36:02 +01:00
rune 305abfa85d Bugfix: Lingering error in image generation from image gen. models 2026-03-04 11:52:18 +01:00
rune c5c2667553 Merge pull request '2.3.6' (#2) from 2.3.6 into main
Reviewed-on: #2
2026-03-04 10:20:25 +01:00
rune f375b1172b Merge pull request 'iCloud Backup, better chatview exp. bugfixes++' (#1) from 2.3.5 into main
Reviewed-on: #1
2026-02-27 14:09:59 +01:00
38 changed files with 3271 additions and 401 deletions
+20 -2
View File
@@ -53,6 +53,15 @@ Seamless conversation backup and sync across devices:
- **Shortcuts** - Personal slash commands that expand to prompt templates; optional `{{input}}` placeholder for inline input
- **Agent Skills (SKILL.md)** - Markdown instruction files injected into the system prompt; compatible with skill0.io, skillsmp.com, and other SKILL.md marketplaces; import as `.md` or `.zip` bundle with attached data files
### 📚 Anytype Integration
Connect oAI to your local [Anytype](https://anytype.io) knowledge base:
- **Search** — find objects by keyword across all spaces or within a specific one
- **Read** — open any object and read its full markdown content
- **Append** — add content to the end of an existing object without touching existing text or internal links (preferred over full update)
- **Create** — make new notes, tasks, or pages
- **Checkbox tools** — surgically toggle to-do checkboxes or set task done/undone via native relation
- All data stays on your machine (local API, no cloud)
### 🖥️ Power-User Features
- **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
@@ -76,8 +85,9 @@ Automated email responses powered by AI:
- Footer stats display (messages, tokens, cost, sync status)
- Header status indicators (MCP, Online mode, Git sync)
- Responsive message layout with copy buttons
- **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠), sort by price or context window, search by name or description, per-row ⓘ info button
- **Localization** - UI fully translated into Norwegian Bokmål, Swedish, Danish, and German; follows macOS language preference automatically
- **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
- **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
![Advanced Features](Screenshots/4.png)
@@ -306,6 +316,8 @@ AI-powered email auto-responder:
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German)
- [x] iCloud Backup (settings export/restore)
- [x] Bash execution with per-command approval
- [x] Anytype integration (read, append, create, checkbox tools)
- [x] Model favourites (starred models, filter, float to top)
- [ ] SOUL.md / USER.md — living identity documents injected into system prompt
- [ ] Parallel research agents (read-only, concurrent)
- [ ] Local embeddings (sentence-transformers, $0 cost)
@@ -338,6 +350,12 @@ Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instru
---
## 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.
---
**⭐ Star this project if you find it useful!**
**🐛 Found a bug?** [Open an issue](https://gitlab.pm/rune/oai-swift/issues/new)
+6 -6
View File
@@ -279,11 +279,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.3.6;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -323,11 +323,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.3.6;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -34,7 +34,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "nb"
language = "en"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
+8
View File
@@ -6,5 +6,13 @@
<string>oAI.help</string>
<key>CFBundleHelpBookName</key>
<string>oAI Help</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>nb</string>
<string>da</string>
<string>de</string>
<string>sv</string>
</array>
</dict>
</plist>
+139
View File
@@ -1,6 +1,13 @@
{
"sourceLanguage" : "en",
"strings" : {
"·" : {
"comment" : "A separator between the message count and the date.",
"isCommentAutoGenerated" : true
},
"· %@" : {
},
"(always used)" : {
"localizations" : {
"da" : {
@@ -432,6 +439,12 @@
}
}
}
},
"^[%@ message](inflect: true)" : {
},
"^[%@ token](inflect: true)" : {
},
"© 2026 [Rune Olsen](https://blog.rune.pm)" : {
"comment" : "A copyright notice with the copyright holder's name.",
@@ -521,6 +534,7 @@
},
"⌘N New • ⌘M Model • ⌘S Save" : {
"comment" : "A hint that appears on macOS when using keyboard shortcuts.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"da" : {
@@ -549,6 +563,10 @@
}
}
},
"⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly." : {
"comment" : "A warning displayed in the settings view.",
"isCommentAutoGenerated" : true
},
"⚠️ Custom prompt active — only this prompt will be sent to the model." : {
"localizations" : {
"da" : {
@@ -894,6 +912,12 @@
}
}
}
},
"🧠" : {
},
"1. Open Anytype → Settings → Integrations" : {
},
"1. Open Paperless-NGX → Settings → API Tokens" : {
"comment" : "A step in the process of getting a Paperless-NGX API token.",
@@ -925,6 +949,10 @@
}
}
},
"2. Create a new API key" : {
"comment" : "A step in the process of getting an API key from Anytype.",
"isCommentAutoGenerated" : true
},
"2. Create or copy your token" : {
"comment" : "A step in the process of getting a Paperless-NGX API token.",
"isCommentAutoGenerated" : true,
@@ -1132,6 +1160,9 @@
}
}
}
},
"Agent" : {
},
"Agent Skills" : {
"localizations" : {
@@ -1160,6 +1191,9 @@
}
}
}
},
"Agents" : {
},
"Allow Shell Command?" : {
"comment" : "A title for a modal that asks the user if they want to allow a shell command.",
@@ -1279,6 +1313,10 @@
}
}
},
"Apple Intelligence" : {
"comment" : "A heading for the Apple Intelligence credits view.",
"isCommentAutoGenerated" : true
},
"Auto-execute mode: commands run without approval. Use with caution." : {
"localizations" : {
"da" : {
@@ -1566,6 +1604,9 @@
}
}
}
},
"Category" : {
},
"Changing these values affects how the AI generates responses. The defaults work well for most use cases." : {
"localizations" : {
@@ -1654,6 +1695,9 @@
}
}
}
},
"Choose an agent from the list to view details and run history" : {
},
"Clear All" : {
"comment" : "A button to clear all email activity logs.",
@@ -1918,6 +1962,9 @@
}
}
}
},
"Cost" : {
},
"Cost Examples" : {
"comment" : "A heading for the cost examples of a model.",
@@ -2092,6 +2139,9 @@
}
}
}
},
"Disabled" : {
},
"Each command will require your approval before running." : {
"localizations" : {
@@ -2468,6 +2518,12 @@
}
}
}
},
"Filter by Category" : {
},
"Generate an API key in your Jarvis settings and paste it above." : {
},
"Google (Gemini embedding)" : {
"localizations" : {
@@ -2526,6 +2582,12 @@
}
}
}
},
"High (~80%)" : {
},
"How to get your API key:" : {
},
"How to get your API token:" : {
"comment" : "A heading for a section that describes how to get your API token.",
@@ -2640,6 +2702,9 @@
}
}
}
},
"Input" : {
},
"Large files inflate the system prompt and may hit token limits." : {
"comment" : "A warning displayed when a user adds a large file to a skill.",
@@ -2726,6 +2791,9 @@
}
}
}
},
"Low (~20%)" : {
},
"Lowercase letters, numbers, and hyphens only. No spaces." : {
"comment" : "A description of the format of a shortcut's command.",
@@ -2842,6 +2910,9 @@
}
}
}
},
"Medium (~50%)" : {
},
"messages" : {
"localizations" : {
@@ -2870,6 +2941,9 @@
}
}
}
},
"Minimal (~10%)" : {
},
"Model Context Protocol" : {
"localizations" : {
@@ -2928,6 +3002,9 @@
}
}
}
},
"Model thinks internally but reasoning is not shown in chat" : {
},
"Multi-provider AI chat client" : {
"comment" : "A description of oAI.",
@@ -3018,6 +3095,9 @@
}
}
}
},
"New Chat" : {
},
"No credit data available" : {
"comment" : "A message displayed when there is no credit data available.",
@@ -3196,6 +3276,9 @@
}
}
}
},
"No runs yet" : {
},
"No shortcuts yet" : {
"comment" : "A message displayed when a user has no shortcuts.",
@@ -3347,6 +3430,10 @@
}
}
},
"oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates." : {
"comment" : "A warning that Intel Macs are no longer supported.",
"isCommentAutoGenerated" : true
},
"Ollama (Local)" : {
"comment" : "A label displayed above the credits information for the local Ollie.",
"isCommentAutoGenerated" : true,
@@ -3377,6 +3464,13 @@
}
}
},
"On-Device (4K context)" : {
},
"On-device and free — no credits or API key needed." : {
"comment" : "A description of the on-device version of the app.",
"isCommentAutoGenerated" : true
},
"Only emails with this text in the subject line will be processed. Example: \"[OAIBOT] What's the weather?\"" : {
"localizations" : {
"da" : {
@@ -3574,6 +3668,9 @@
}
}
}
},
"OpenRouter Balance" : {
},
"OpenRouter Credits" : {
"comment" : "A heading for the user's OpenRouter credits.",
@@ -3604,6 +3701,12 @@
}
}
}
},
"Output" : {
},
"Prompt" : {
},
"Read access (always enabled)" : {
"localizations" : {
@@ -3632,6 +3735,9 @@
}
}
}
},
"Reasoning" : {
},
"Remote: %@" : {
"localizations" : {
@@ -3690,6 +3796,12 @@
}
}
}
},
"Run History" : {
},
"Run some agents to see usage statistics" : {
},
"Running locally — no credits needed!" : {
"comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.",
@@ -3721,6 +3833,10 @@
}
}
},
"Runs" : {
"comment" : "A column header for the number of runs.",
"isCommentAutoGenerated" : true
},
"Security Recommendation" : {
"localizations" : {
"da" : {
@@ -3866,6 +3982,9 @@
}
}
}
},
"Sort" : {
},
"SSH Key" : {
"localizations" : {
@@ -4180,6 +4299,9 @@
}
}
}
},
"Thinking…" : {
},
"This default prompt is always included to ensure accurate, helpful responses." : {
"localizations" : {
@@ -4296,6 +4418,15 @@
}
}
}
},
"Total" : {
},
"Total Credits" : {
},
"Total Used" : {
},
"Try adjusting your search or filters" : {
"comment" : "A description of the error that occurs when no models match the user's search.",
@@ -4444,6 +4575,9 @@
}
}
}
},
"Usage" : {
},
"Use @filename to attach files to your message" : {
"comment" : "A description of how to attach files to a message.",
@@ -4565,6 +4699,7 @@
},
"v%@" : {
"comment" : "A label showing the current version of oAI.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"da" : {
@@ -4744,6 +4879,10 @@
}
}
}
},
"β" : {
"comment" : "A beta badge.",
"isCommentAutoGenerated" : true
}
},
"version" : "1.1"
+226
View File
@@ -0,0 +1,226 @@
//
// JarvisModels.swift
// oAI
//
// Data models for the Jarvis (oAI-Web) API integration.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import Foundation
// MARK: - Agent
struct JarvisAgent: Identifiable, Codable, Hashable, Sendable {
let id: String
var name: String
var description: String
var prompt: String
var model: String
var enabled: Bool
var schedule: String?
var canCreateSubagents: Bool
var allowedTools: [String]
var maxToolCalls: Int?
var promptMode: String
let createdAt: String?
var isRunning: Bool?
var lastRunAt: String?
var lastRunStatus: String?
enum CodingKeys: String, CodingKey {
case id, name, description, prompt, model, enabled, schedule
case canCreateSubagents = "can_create_subagents"
case allowedTools = "allowed_tools"
case maxToolCalls = "max_tool_calls"
case promptMode = "prompt_mode"
case createdAt = "created_at"
case isRunning = "is_running"
case lastRunAt = "last_run_at"
case lastRunStatus = "last_run_status"
}
}
// MARK: - Agent Input (create / update)
struct JarvisAgentInput: Codable, Sendable {
var name: String
var prompt: String
var model: String
var description: String = ""
var enabled: Bool = true
var schedule: String? = nil
var canCreateSubagents: Bool = false
var allowedTools: [String] = []
var maxToolCalls: Int? = nil
var promptMode: String = "combined"
enum CodingKeys: String, CodingKey {
case name, prompt, model, description, enabled, schedule
case canCreateSubagents = "can_create_subagents"
case allowedTools = "allowed_tools"
case maxToolCalls = "max_tool_calls"
case promptMode = "prompt_mode"
}
}
// MARK: - Agent Run
struct JarvisAgentRun: Identifiable, Codable, Sendable {
let id: String
let agentId: String?
let status: String // "running" | "completed" | "failed" | "stopped"
let startedAt: String?
let finishedAt: String?
let output: String?
let error: String?
let costUsd: Double?
let inputTokens: Int?
let outputTokens: Int?
let triggerType: String?
enum CodingKeys: String, CodingKey {
case id, status, output, error
case agentId = "agent_id"
case startedAt = "started_at"
case finishedAt = "finished_at"
case costUsd = "cost_usd"
case inputTokens = "input_tokens"
case outputTokens = "output_tokens"
case triggerType = "trigger_type"
}
var isActive: Bool { status == "running" }
var totalTokens: Int { (inputTokens ?? 0) + (outputTokens ?? 0) }
var formattedStarted: String {
guard let s = startedAt else { return "" }
return isoFormatter.string(from: isoParser.date(from: s) ?? Date())
}
var formattedDuration: String? {
guard let s = startedAt, let f = finishedAt,
let sd = isoParser.date(from: s), let fd = isoParser.date(from: f) else { return nil }
let secs = Int(fd.timeIntervalSince(sd))
if secs < 60 { return "\(secs)s" }
return "\(secs / 60)m \(secs % 60)s"
}
}
private let isoParser: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private let isoFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .short
f.timeStyle = .short
return f
}()
// MARK: - Usage
struct JarvisUsageStat: Identifiable, Codable, Sendable {
let agentId: String?
let agentName: String?
let model: String?
let runCount: Int?
let totalInputTokens: Int?
let totalOutputTokens: Int?
let totalCostUsd: Double?
var id: String { agentId ?? agentName ?? "unknown" }
var totalTokens: Int { (totalInputTokens ?? 0) + (totalOutputTokens ?? 0) }
var displayName: String { agentName ?? agentId ?? "Unknown" }
enum CodingKeys: String, CodingKey {
case agentId = "agent_id"
case agentName = "agent_name"
case model
case runCount = "runs"
case totalInputTokens = "input_tokens"
case totalOutputTokens = "output_tokens"
case totalCostUsd = "cost_usd"
}
}
// MARK: - Usage Response (top-level wrapper)
struct JarvisUsageResponse: Decodable, Sendable {
let summary: JarvisUsageSummary?
let byAgent: [JarvisUsageStat]?
enum CodingKeys: String, CodingKey {
case summary
case byAgent = "by_agent"
}
}
struct JarvisUsageSummary: Codable, Sendable {
let runs: Int?
let inputTokens: Int?
let outputTokens: Int?
let costUsd: Double?
enum CodingKeys: String, CodingKey {
case runs
case inputTokens = "input_tokens"
case outputTokens = "output_tokens"
case costUsd = "cost_usd"
}
}
// MARK: - Credits
struct JarvisCreditsResponse: Codable, Sendable {
let totalCredits: Double?
let totalUsage: Double?
let balance: Double?
enum CodingKeys: String, CodingKey {
case totalCredits = "total_credits"
case totalUsage = "total_usage"
case balance
}
var remainingBalance: Double? {
if let b = balance { return b }
if let c = totalCredits, let u = totalUsage { return c - u }
return nil
}
}
// MARK: - Queue / System status
struct JarvisQueueStatus: Codable, Sendable {
let paused: Bool?
let queueLength: Int?
let runningCount: Int?
enum CodingKeys: String, CodingKey {
case paused
case queueLength = "queue_length"
case runningCount = "running_count"
}
}
// MARK: - Errors
enum JarvisError: LocalizedError {
case invalidURL
case noAPIKey
case invalidResponse
case serverError(Int, String)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid Jarvis URL"
case .noAPIKey: return "No API key configured — add one in Settings → Jarvis"
case .invalidResponse: return "Invalid server response"
case .serverError(let c, let m): return "Server error \(c): \(m)"
}
}
}
+176
View File
@@ -0,0 +1,176 @@
//
// ModelCategory.swift
// oAI
//
// Category tags for AI models, inferred from model name/id/description.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import SwiftUI
enum ModelCategory: String, CaseIterable, Codable, Sendable {
case programming = "Programming"
case math = "Math"
case medical = "Medical"
case translation = "Translation"
case roleplay = "Roleplay"
case creative = "Creative"
case science = "Science"
case finance = "Finance"
case legal = "Legal"
var color: Color {
switch self {
case .programming: return .blue
case .math: return .orange
case .medical: return .red
case .translation: return .teal
case .roleplay: return .pink
case .creative: return .purple
case .science: return .green
case .finance: return Color(red: 0.75, green: 0.60, blue: 0.0)
case .legal: return Color(red: 0.55, green: 0.40, blue: 0.20)
}
}
var systemImage: String {
switch self {
case .programming: return "chevron.left.forwardslash.chevron.right"
case .math: return "function"
case .medical: return "cross.fill"
case .translation: return "globe"
case .roleplay: return "theatermasks.fill"
case .creative: return "pencil.and.outline"
case .science: return "atom"
case .finance: return "chart.line.uptrend.xyaxis"
case .legal: return "building.columns.fill"
}
}
// MARK: - Category Inference
/// Infer categories from a model's name, id, and description.
static func infer(name: String, id: String, description: String?) -> [ModelCategory] {
let nameId = (name + " " + id).lowercased()
let desc = description?.lowercased() ?? ""
return allCases.filter { $0.matches(nameId: nameId, desc: desc) }
}
private func matches(nameId: String, desc: String) -> Bool {
if nameKeywords.contains(where: { nameId.contains($0) }) { return true }
if desc.count > 40 && descKeywords.contains(where: { desc.contains($0) }) { return true }
return false
}
// Patterns matched against lowercased "name + id" string
private var nameKeywords: [String] {
switch self {
case .programming:
return ["code", "coder", "codex", "codellama", "starcoder", "phind",
"codestral", "opencoder", "swe-", "devin-", "wizard-code",
"replit-code", "qwen-coder", "deepseek-coder", "devstral",
"granite-code", "yi-coder", "artigenz", "wavecoder",
"programming", "software-", "cursor-"]
case .math:
return ["math", "mathem", "numina", "minerva-math", "wizard-math",
"deepseek-math", "qwen-math", "numinamath", "mathstral",
"qwq", "internlm-math", "mammoth", "mathcoder", "orion-math",
"abel-", "metamath"]
case .medical:
return ["medical", "meditron", "med42", "medllama", "biomed",
"health-llm", "biosage", "clinicalbert", "pubmedbert",
"clinical", "llama-med", "openbiomed", "pmc-llama",
"doctorglm", "biolm", "biomistral", "medalpaca",
"medpalm", "pharmallm", "mimic"]
case .translation:
return ["nllb", "madlad", "-aya-", "seamless", "tower-instruct",
"alma-", "bayling", "opus-mt", "m2m-100", "mbart",
"translate", "multilingual-", "xglm", "madlad-400"]
case .roleplay:
return ["roleplay", "role-play", "mytho", "capybara", "cinematika",
"manticore", "weaver-", "noromaid", "airoboros", "toppy",
"dolphin", "hermes", "openhermes", "psyfighter",
"bluemoon", "midnight", "remm", "rose-20b"]
case .creative:
return ["creative-writing", "story-writer", "storyllm", "fimbulvetr",
"rp-", "goliath", "lzlv", "mlewd"]
case .science:
return ["scibert", "biogpt", "galactica", "science-llm", "scillm",
"darwin-", "newton-", "eureka-", "sci-"]
case .finance:
return ["fingpt", "finma", "finance-llm", "financellm", "pixiu",
"flang-", "alphafin", "bloom-finance", "finbert",
"stockgpt", "traderllm"]
case .legal:
return ["lawbench", "legalbench", "legalbert", "lawgpt", "legal-llm",
"lawyerllm", "legalai", "chatlaw", "jurisllm"]
}
}
// Phrases matched against lowercased description (only when description > 40 chars)
private var descKeywords: [String] {
switch self {
case .programming:
return ["code generation", "designed for coding", "built for code",
"coding-focused", "programming assistant", "specialized for code",
"optimized for coding", "coding tasks", "software engineering",
"for developers", "code completion", "software development",
"coding and", "and coding", "writing code"]
case .math:
return ["mathematical reasoning", "math competition", "math olympiad",
"designed for math", "theorem proving", "quantitative reasoning",
"numerical problem", "math, code", "math and code",
"mathematics and", "advanced math", "math tasks",
"solving math", "competition math", "math problems"]
case .medical:
return ["medical knowledge", "clinical reasoning", "healthcare",
"biomedical research", "medical question", "trained on medical",
"medical domain", "medical literature", "clinical decision",
"health information", "medical text", "medical imaging"]
case .translation:
return ["machine translation", "language translation",
"multilingual translation", "cross-lingual",
"translation tasks", "translation between",
"natural language translation"]
case .roleplay:
return ["designed for roleplay", "roleplay scenarios",
"creative roleplay", "interactive roleplay", "character roleplay",
"role-playing", "roleplaying", "uncensored", "nsfw",
"adult content", "creative fiction", "interactive story"]
case .creative:
return ["creative writing", "storytelling", "narrative generation",
"fiction writing", "prose generation", "story writing",
"creative text", "story generation", "write stories"]
case .science:
return ["scientific literature", "scientific research",
"chemistry tasks", "biology research", "physics",
"scientific reasoning", "science tasks", "stem tasks",
"scientific knowledge"]
case .finance:
return ["financial analysis", "quantitative finance",
"financial modeling", "market analysis", "financial reasoning",
"investment analysis", "economic analysis", "trading"]
case .legal:
return ["legal document", "case law", "legal reasoning",
"legal text", "law and legal", "legal questions",
"legal analysis", "legal research", "contract analysis"]
}
}
}
+1
View File
@@ -34,6 +34,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
let capabilities: ModelCapabilities
var architecture: Architecture? = nil
var topProvider: String? = nil
var categories: [ModelCategory] = []
struct Pricing: Codable, Hashable {
let prompt: Double // per 1M tokens
+13 -5
View File
@@ -58,17 +58,25 @@ struct Settings: Codable {
case anthropic
case openai
case ollama
case appleOnDevice = "apple_on_device"
var displayName: String {
rawValue.capitalized
switch self {
case .openrouter: return "OpenRouter"
case .anthropic: return "Anthropic"
case .openai: return "OpenAI"
case .ollama: return "Ollama"
case .appleOnDevice: return "Apple Intelligence"
}
}
var iconName: String {
switch self {
case .openrouter: return "network"
case .anthropic: return "brain"
case .openai: return "sparkles"
case .ollama: return "server.rack"
case .openrouter: return "network"
case .anthropic: return "brain"
case .openai: return "sparkles"
case .ollama: return "server.rack"
case .appleOnDevice: return "apple.logo"
}
}
}
+195
View File
@@ -0,0 +1,195 @@
//
// AppleFoundationProvider.swift
// oAI
//
// Apple Foundation Models provider (on-device Apple Intelligence)
//
// 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 FoundationModels
import os
final class AppleFoundationProvider: AIProvider {
let name = "Apple Intelligence"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
supportsOnlineSearch: false,
maxContextLength: 4096
)
// MARK: - Models
func listModels() async throws -> [ModelInfo] {
[
ModelInfo(
id: "apple-on-device",
name: "Apple On-Device",
description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window.",
contextLength: 4096,
pricing: ModelInfo.Pricing(prompt: 0, completion: 0),
capabilities: ModelInfo.ModelCapabilities(
vision: false,
tools: false,
online: false
)
)
]
}
func getModel(_ id: String) async throws -> ModelInfo? {
try await listModels().first { $0.id == id }
}
func getCredits() async throws -> Credits? { nil }
// MARK: - Streaming chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
AsyncThrowingStream { continuation in
Task {
do {
let session = try self.makeSession(for: request)
let prompt = self.lastUserMessage(from: request)
// streamResponse(to: String) ResponseStream<String>
// Each snapshot.content is the full accumulated text so far (snapshot model).
// We compute deltas by comparing each snapshot to the previous.
let stream = session.streamResponse(to: prompt)
var lastContent = ""
for try await snapshot in stream {
let current = snapshot.content
if current.count > lastContent.count {
let delta = String(current.dropFirst(lastContent.count))
continuation.yield(StreamChunk(
id: UUID().uuidString,
model: request.model,
delta: StreamChunk.Delta(content: delta, role: "assistant"),
finishReason: nil,
usage: nil
))
lastContent = current
}
}
continuation.yield(StreamChunk(
id: UUID().uuidString,
model: request.model,
delta: StreamChunk.Delta(content: nil, role: nil),
finishReason: "stop",
usage: nil
))
continuation.finish()
} catch let genError as LanguageModelSession.GenerationError {
continuation.finish(throwing: self.mapGenerationError(genError))
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Non-streaming chat
func chat(request: ChatRequest) async throws -> ChatResponse {
let session = try makeSession(for: request)
let prompt = lastUserMessage(from: request)
let response: LanguageModelSession.Response<String> = try await session.respond(to: prompt)
return ChatResponse(
id: UUID().uuidString,
model: request.model,
content: response.content,
role: "assistant",
finishReason: "stop",
usage: nil,
created: Date()
)
}
// MARK: - Tool messages (not supported in Phase 1)
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
throw ProviderError.unknown("Tool calling requires Apple Foundation Models Phase 3.")
}
// MARK: - Session construction
private func makeSession(for request: ChatRequest) throws -> LanguageModelSession {
guard case .available = SystemLanguageModel.default.availability else {
throw availabilityError()
}
// Build instructions: system prompt + prior conversation turns as formatted text.
// Foundation Models sessions don't accept a message array we inject history inline.
var instructions = request.systemPrompt ?? ""
let priorMessages = request.messages.dropLast().filter { $0.role != .system }
if !priorMessages.isEmpty {
let history = priorMessages
.map { m -> String in
let label = m.role == .user ? "User" : "Assistant"
return "\(label): \(m.content)"
}
.joined(separator: "\n")
instructions += "\n\nConversation so far:\n\(history)\n\nContinue from here."
}
return instructions.isEmpty
? LanguageModelSession()
: LanguageModelSession(instructions: instructions)
}
private func lastUserMessage(from request: ChatRequest) -> String {
request.messages.last(where: { $0.role == .user })?.content ?? ""
}
// MARK: - Error mapping
private func availabilityError() -> Error {
switch SystemLanguageModel.default.availability {
case .unavailable(.deviceNotEligible):
return ProviderError.unknown("This Mac doesn't support Apple Intelligence. Apple Silicon is required.")
case .unavailable(.appleIntelligenceNotEnabled):
return ProviderError.unknown("Apple Intelligence is not enabled. Open System Settings → Apple Intelligence to turn it on.")
case .unavailable(.modelNotReady):
return ProviderError.unknown("Apple Intelligence model is still downloading. Please wait and try again.")
default:
return ProviderError.unknown("Apple Intelligence is not available on this device.")
}
}
private func mapGenerationError(_ error: LanguageModelSession.GenerationError) -> Error {
switch error {
case .exceededContextWindowSize:
return ProviderError.unknown("Apple Intelligence context limit exceeded (4,096 tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.")
case .rateLimited:
return ProviderError.rateLimitExceeded
case .guardrailViolation:
return ProviderError.unknown("Apple Intelligence declined to respond to this message.")
default:
return error
}
}
}
+33
View File
@@ -160,6 +160,18 @@ struct OpenRouterChatResponse: Codable {
let content: String?
let toolCalls: [APIToolCall]?
let images: [ImageOutput]?
// Images extracted from content[] blocks (e.g. GPT-5 Image response format)
let contentBlockImages: [ImageOutput]
private struct ContentBlock: Codable {
let type: String
let text: String?
let imageUrl: ImageOutput.ImageURL?
enum CodingKeys: String, CodingKey {
case type, text
case imageUrl = "image_url"
}
}
enum CodingKeys: String, CodingKey {
case role
@@ -167,6 +179,27 @@ struct OpenRouterChatResponse: Codable {
case toolCalls = "tool_calls"
case images
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
role = try c.decode(String.self, forKey: .role)
toolCalls = try c.decodeIfPresent([APIToolCall].self, forKey: .toolCalls)
images = try c.decodeIfPresent([ImageOutput].self, forKey: .images)
// content can be a plain String OR an array of content blocks
if let text = try? c.decodeIfPresent(String.self, forKey: .content) {
content = text
contentBlockImages = []
} else if let blocks = try? c.decodeIfPresent([ContentBlock].self, forKey: .content) {
content = blocks.compactMap { $0.text }.joined().nonEmptyOrNil
contentBlockImages = blocks.compactMap { block in
guard block.type == "image_url", let url = block.imageUrl else { return nil }
return ImageOutput(imageUrl: url)
}
} else {
content = nil
contentBlockImages = []
}
}
}
enum CodingKeys: String, CodingKey {
+21 -3
View File
@@ -80,7 +80,7 @@ class OpenRouterProvider: AIProvider {
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
return ModelInfo(
var info = ModelInfo(
id: modelData.id,
name: modelData.name,
description: modelData.description,
@@ -122,6 +122,12 @@ class OpenRouterProvider: AIProvider {
},
topProvider: modelData.id.components(separatedBy: "/").first
)
info.categories = ModelCategory.infer(
name: modelData.name,
id: modelData.id,
description: modelData.description
)
return info
}
}
@@ -160,8 +166,17 @@ class OpenRouterProvider: AIProvider {
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
// Debug: log raw response for image gen models
if request.imageGeneration, let rawStr = String(data: data, encoding: .utf8) {
Log.api.debug("Image gen raw response (first 3000 chars): \(rawStr.prefix(3000))")
}
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return try convertToChatResponse(apiResponse)
let chatResponse = try convertToChatResponse(apiResponse)
if request.imageGeneration {
Log.api.debug("Image gen decoded: content='\(chatResponse.content)', generatedImages=\(chatResponse.generatedImages?.count ?? 0)")
}
return chatResponse
}
// MARK: - Chat with raw tool messages
@@ -396,7 +411,10 @@ class OpenRouterProvider: AIProvider {
ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments)
}
let images = choice.message.images.flatMap { decodeImageOutputs($0) }
let topLevelImages = choice.message.images.flatMap { decodeImageOutputs($0) } ?? []
let blockImages = decodeImageOutputs(choice.message.contentBlockImages) ?? []
let allImages = topLevelImages + blockImages
let images: [Data]? = allImages.isEmpty ? nil : allImages
return ChatResponse(
id: apiResponse.id,
+5
View File
@@ -69,6 +69,9 @@ class ProviderRegistry {
case .ollama:
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
case .appleOnDevice:
provider = AppleFoundationProvider()
}
// Cache and return
@@ -106,6 +109,8 @@ class ProviderRegistry {
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
case .ollama:
return settings.ollamaConfigured
case .appleOnDevice:
return true // no API key needed
}
}
@@ -35,6 +35,7 @@
<li><a href="#email-handler">Email Handler (AI Assistant)</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="#anytype">Anytype Integration</a></li>
<li><a href="#bash-execution">Bash Execution</a></li>
<li><a href="#icloud-backup">iCloud Backup</a></li>
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
@@ -117,14 +118,23 @@
<h3>Sorting</h3>
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
<ul>
<li><strong>Default</strong> — provider order</li>
<li><strong>Default</strong> — provider order, with favourites floated to the top</li>
<li><strong>Price: Low to High</strong> — cheapest per million tokens first</li>
<li><strong>Price: High to Low</strong> — most capable/expensive first</li>
<li><strong>Context: High to Low</strong> — largest context window first</li>
</ul>
<h3>Favourite Models</h3>
<p>Click the <strong></strong> star next to any model name to mark it as a favourite. Favourites:</p>
<ul>
<li>Float to the top of the Default sort order</li>
<li>Can be filtered to show only with the <strong></strong> star button in the toolbar</li>
<li>Are shown as a filled yellow star ★ in the model row, the Model Info sheet, and the header bar</li>
<li>Are shared across all three places — toggling in any one updates all</li>
</ul>
<h3>Model Information</h3>
<p>Click the <strong></strong> icon on any model row to open a full details sheet — context length, pricing, capabilities, and description — without selecting that model. You can also type:</p>
<p>Click the <strong></strong> icon on any model row to open a full details sheet — context length, pricing, capabilities, and description — without selecting that model. The sheet also has a ★ star button to toggle favourites. You can also type:</p>
<code class="command">/info</code>
<p class="note">Shows information about the currently selected model.</p>
@@ -132,7 +142,7 @@
<p>Use <kbd></kbd> / <kbd></kbd> to move through the list, <kbd>Return</kbd> to select the highlighted model.</p>
<h3>Default Model</h3>
<p>Your selected model is automatically saved and will be restored when you restart the app.</p>
<p>Set a model that oAI always opens with in <strong>Settings → General → Model Settings → Default Model</strong>. Click <strong>Choose…</strong> to pick from the full model list, or <strong>Clear</strong> to remove the default. Switching models during a chat session does <em>not</em> change your saved default — it only changes the current session.</p>
</section>
<!-- Sending Messages -->
@@ -1361,6 +1371,48 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
</section>
<!-- Keyboard Shortcuts -->
<!-- Anytype Integration -->
<section id="anytype">
<h2>Anytype Integration</h2>
<p>oAI can connect to your local <a href="https://anytype.io" target="_blank">Anytype</a> desktop app, giving the AI read and write access to your personal knowledge base. All data stays on your machine — the API is local-only.</p>
<h3>Requirements</h3>
<ul>
<li>Anytype desktop app installed and running</li>
<li>An API key generated inside Anytype (Settings → Integrations)</li>
</ul>
<h3>Setup</h3>
<ol>
<li>Open oAI Settings → Anytype tab</li>
<li>Enable the toggle</li>
<li>Enter your API key (leave the URL as the default unless your setup is unusual)</li>
<li>Click <strong>Test Connection</strong> — a success message will show how many spaces were found</li>
</ol>
<h3>What the AI Can Do</h3>
<ul>
<li><strong>Search</strong> — find objects by keyword across all spaces or within a specific one</li>
<li><strong>Read</strong> — open any object and read its full markdown content</li>
<li><strong>Create</strong> — make new notes, tasks, or pages</li>
<li><strong>Append</strong> — add content to the end of an existing object without touching the rest (recommended for edits)</li>
<li><strong>Update</strong> — rewrite the full body of an object (use only when truly restructuring content)</li>
<li><strong>Checkboxes</strong> — toggle individual to-do checkboxes by text match, or mark tasks done via their native relation</li>
</ul>
<div class="tip">
<strong>💡 Tip — Append vs Update:</strong> Use <em>append</em> whenever you want to add content to an existing note. It fetches the current body, adds your new content at the end, and saves — leaving all existing text, links, and internal Anytype references intact. <em>Update</em> replaces the entire body and can degrade rich Anytype internal links (anytype://...) to plain text.
</div>
<h3>Example Prompts</h3>
<ul>
<li>"Search my Anytype for notes about Swift concurrency"</li>
<li>"Create a new task called 'Review PR #42' in my Work space"</li>
<li>"Add today's meeting summary to my Weekly Notes object"</li>
<li>"Mark the 'Buy groceries' checkbox as done in my Shopping List"</li>
</ul>
</section>
<section id="keyboard-shortcuts">
<h2>Keyboard Shortcuts</h2>
<p>Work faster with these keyboard shortcuts.</p>
@@ -1549,6 +1601,27 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<li><strong>Restore from File…</strong> — imports settings from a backup file</li>
<li>API keys and credentials are excluded from backups and must be re-entered after restore</li>
</ul>
<h3>Anytype Tab</h3>
<p>Connect oAI to your local <a href="https://anytype.io" target="_blank">Anytype</a> desktop app so the AI can search, read, and add content to your knowledge base.</p>
<ul>
<li><strong>Enable Anytype</strong> — toggle to activate the integration</li>
<li><strong>API URL</strong> — local Anytype API endpoint (default: <code>http://127.0.0.1:31009</code>)</li>
<li><strong>API Key</strong> — generated in Anytype → Settings → Integrations</li>
<li><strong>Test Connection</strong> — verify connectivity and list available spaces</li>
</ul>
<p>When enabled, the AI has access to these tools:</p>
<ul>
<li><code>anytype_search_global</code> / <code>anytype_search_space</code> — search across all or a specific space</li>
<li><code>anytype_list_spaces</code> / <code>anytype_get_space_objects</code> — explore your spaces</li>
<li><code>anytype_get_object</code> — read the full markdown body of any object</li>
<li><code>anytype_create_object</code> — create a new note, task, or page</li>
<li><code>anytype_append_to_object</code><strong>add content to an existing object without rewriting it</strong> (preferred for edits — preserves Anytype internal links)</li>
<li><code>anytype_update_object</code> — replace the full body (use sparingly; prefer append)</li>
<li><code>anytype_toggle_checkbox</code> — surgically check/uncheck a to-do item by text match</li>
<li><code>anytype_set_done</code> — mark a task done/undone via its native relation</li>
</ul>
<p class="note"><strong>Note:</strong> The Anytype desktop app must be running for the integration to work. The API is local-only — no data leaves your machine.</p>
</section>
<!-- System Prompts -->
+49 -3
View File
@@ -105,13 +105,28 @@ class AnytypeMCPService {
],
required: ["space_id", "name"]
),
makeTool(
name: "anytype_append_to_object",
description: """
Append new markdown content to the end of an existing Anytype object without touching the existing body. \
This is the PREFERRED way to add content to existing notes, pages, or tasks — \
it preserves all Anytype internal links (anytype://...) and mention blocks exactly. \
Use this instead of anytype_update_object whenever you are adding information rather than rewriting.
""",
properties: [
"space_id": prop("string", "The ID of the space containing the object"),
"object_id": prop("string", "The ID of the object to append to"),
"content": prop("string", "Markdown content to append at the end of the object body")
],
required: ["space_id", "object_id", "content"]
),
makeTool(
name: "anytype_update_object",
description: """
Replace the full markdown body or rename an Anytype object. \
IMPORTANT: For toggling a checkbox (to-do item), use anytype_toggle_checkbox instead — \
it is safer and does not risk modifying other content. \
Use anytype_update_object ONLY for large content changes (adding paragraphs, rewriting sections, etc.). \
WARNING: This replaces the ENTIRE body — prefer anytype_append_to_object for adding content \
to existing objects, as full replacement degrades rich Anytype internal links (anytype://...) to plain text. \
Use this ONLY when you truly need to rewrite or restructure existing content. \
CRITICAL RULES when using this tool: \
1) Always call anytype_get_object first to get the current EXACT markdown. \
2) Make ONLY the minimal requested change — nothing else. \
@@ -214,6 +229,14 @@ class AnytypeMCPService {
let type_ = args["type"] as? String ?? "note"
return try await createObject(spaceId: spaceId, name: name, body: body, type: type_)
case "anytype_append_to_object":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String,
let content = args["content"] as? String else {
return ["error": "Missing required parameters: space_id, object_id, content"]
}
return try await appendToObject(spaceId: spaceId, objectId: objectId, content: content)
case "anytype_update_object":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String else {
@@ -351,6 +374,29 @@ class AnytypeMCPService {
return ["success": true, "message": "Object created"]
}
private func appendToObject(spaceId: String, objectId: String, content: String) async throws -> [String: Any] {
// Fetch current body
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil)
guard let object = result["object"] as? [String: Any] else {
return ["error": "Object not found"]
}
let existing: String
if let md = object["markdown"] as? String { existing = md }
else if let body = object["body"] as? String { existing = body }
else { existing = "" }
let separator = existing.isEmpty ? "" : "\n\n"
let newMarkdown = existing + separator + content
_ = try await request(
endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)",
method: "PATCH",
body: ["markdown": newMarkdown]
)
return ["success": true, "message": "Content appended successfully"]
}
private func updateObject(spaceId: String, objectId: String, name: String?, body: String?) async throws -> [String: Any] {
var requestBody: [String: Any] = [:]
if let name = name { requestBody["name"] = name }
+41 -27
View File
@@ -134,15 +134,29 @@ final class DatabaseService: Sendable {
nonisolated static let shared = DatabaseService()
private let dbQueue: DatabaseQueue
private let isoFormatter: ISO8601DateFormatter
// Command history limit - keep most recent 5000 entries
private static let maxHistoryEntries = 5000
private nonisolated static let maxHistoryEntries = 5000
// ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable).
private nonisolated static let isoStyle = Date.ISO8601FormatStyle(
dateSeparator: .dash,
dateTimeSeparator: .standard,
timeSeparator: .colon,
timeZoneSeparator: .colon,
includingFractionalSeconds: true,
timeZone: .gmt
)
private nonisolated static func isoString(from date: Date) -> String {
isoStyle.format(date)
}
private nonisolated static func isoDate(from string: String) -> Date? {
(try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
}
nonisolated private init() {
isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fileManager = FileManager.default
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
@@ -156,7 +170,7 @@ final class DatabaseService: Sendable {
try! migrator.migrate(dbQueue)
}
private var migrator: DatabaseMigrator {
private nonisolated var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in
@@ -375,7 +389,7 @@ final class DatabaseService: Sendable {
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
let now = Date()
let nowString = isoFormatter.string(from: now)
let nowString = Self.isoString(from: now)
let convRecord = ConversationRecord(
id: id.uuidString,
@@ -394,7 +408,7 @@ final class DatabaseService: Sendable {
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp),
timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index,
modelId: msg.modelId
)
@@ -420,7 +434,7 @@ final class DatabaseService: Sendable {
/// Update an existing conversation in-place: rename it, replace all its messages.
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
let nowString = isoFormatter.string(from: Date())
let nowString = Self.isoString(from: Date())
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
@@ -431,7 +445,7 @@ final class DatabaseService: Sendable {
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp),
timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index,
modelId: msg.modelId
)
@@ -466,7 +480,7 @@ final class DatabaseService: Sendable {
let messages = messageRecords.compactMap { record -> Message? in
guard let msgId = UUID(uuidString: record.id),
let role = MessageRole(rawValue: record.role),
let timestamp = self.isoFormatter.date(from: record.timestamp)
let timestamp = Self.isoDate(from: record.timestamp)
else { return nil }
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
@@ -484,8 +498,8 @@ final class DatabaseService: Sendable {
}
guard let convId = UUID(uuidString: convRecord.id),
let createdAt = self.isoFormatter.date(from: convRecord.createdAt),
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt)
let createdAt = Self.isoDate(from: convRecord.createdAt),
let updatedAt = Self.isoDate(from: convRecord.updatedAt)
else { return nil }
let conversation = Conversation(
@@ -509,8 +523,8 @@ final class DatabaseService: Sendable {
return records.compactMap { record -> Conversation? in
guard let id = UUID(uuidString: record.id),
let createdAt = self.isoFormatter.date(from: record.createdAt),
let updatedAt = self.isoFormatter.date(from: record.updatedAt)
let createdAt = Self.isoDate(from: record.createdAt),
let updatedAt = Self.isoDate(from: record.updatedAt)
else { return nil }
// Fetch message count without loading all messages
@@ -524,7 +538,7 @@ final class DatabaseService: Sendable {
.order(Column("sortOrder").desc)
.fetchOne(db)
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt
let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt
// Derive primary model: prefer the stored field, fall back to last message's modelId
let primaryModel = record.primaryModel ?? lastMsg?.modelId
@@ -574,7 +588,7 @@ final class DatabaseService: Sendable {
convRecord.name = name
}
convRecord.updatedAt = self.isoFormatter.string(from: Date())
convRecord.updatedAt = Self.isoString(from: Date())
try convRecord.update(db)
if let messages = messages {
@@ -589,7 +603,7 @@ final class DatabaseService: Sendable {
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: self.isoFormatter.string(from: msg.timestamp),
timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index
)
}
@@ -610,7 +624,7 @@ final class DatabaseService: Sendable {
let record = HistoryRecord(
id: UUID().uuidString,
input: input,
timestamp: isoFormatter.string(from: now)
timestamp: Self.isoString(from: now)
)
try? dbQueue.write { db in
@@ -643,7 +657,7 @@ final class DatabaseService: Sendable {
.fetchAll(db)
return records.compactMap { record in
guard let date = isoFormatter.date(from: record.timestamp) else {
guard let date = Self.isoDate(from: record.timestamp) else {
return nil
}
return (input: record.input, timestamp: date)
@@ -659,7 +673,7 @@ final class DatabaseService: Sendable {
.fetchAll(db)
return records.compactMap { record in
guard let date = isoFormatter.date(from: record.timestamp) else {
guard let date = Self.isoDate(from: record.timestamp) else {
return nil
}
return (input: record.input, timestamp: date)
@@ -672,7 +686,7 @@ final class DatabaseService: Sendable {
nonisolated func saveEmailLog(_ log: EmailLog) {
let record = EmailLogRecord(
id: log.id.uuidString,
timestamp: isoFormatter.string(from: log.timestamp),
timestamp: Self.isoString(from: log.timestamp),
sender: log.sender,
subject: log.subject,
emailContent: log.emailContent,
@@ -698,7 +712,7 @@ final class DatabaseService: Sendable {
.fetchAll(db)
return records.compactMap { record in
guard let timestamp = isoFormatter.date(from: record.timestamp),
guard let timestamp = Self.isoDate(from: record.timestamp),
let status = EmailLogStatus(rawValue: record.status),
let id = UUID(uuidString: record.id) else {
return nil
@@ -805,7 +819,7 @@ final class DatabaseService: Sendable {
// MARK: - Embedding Operations
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
let now = isoFormatter.string(from: Date())
let now = Self.isoString(from: Date())
let record = MessageEmbeddingRecord(
message_id: messageId.uuidString,
embedding: embedding,
@@ -825,7 +839,7 @@ final class DatabaseService: Sendable {
}
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
let now = isoFormatter.string(from: Date())
let now = Self.isoString(from: Date())
let record = ConversationEmbeddingRecord(
conversation_id: conversationId.uuidString,
embedding: embedding,
@@ -881,7 +895,7 @@ final class DatabaseService: Sendable {
return Array(results.prefix(limit))
}
private func deserializeEmbedding(_ data: Data) -> [Float] {
private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
var embedding: [Float] = []
embedding.reserveCapacity(data.count / 4)
@@ -905,7 +919,7 @@ final class DatabaseService: Sendable {
model: String?,
tokenCount: Int?
) throws {
let now = isoFormatter.string(from: Date())
let now = Self.isoString(from: Date())
let record = ConversationSummaryRecord(
id: UUID().uuidString,
conversation_id: conversationId.uuidString,
+2 -2
View File
@@ -71,7 +71,7 @@ enum EmbeddingProvider {
// MARK: - Embedding Service
final class EmbeddingService {
static let shared = EmbeddingService()
nonisolated static let shared = EmbeddingService()
private let settings = SettingsService.shared
@@ -281,7 +281,7 @@ final class EmbeddingService {
// MARK: - Similarity Calculation
/// Calculate cosine similarity between two embeddings
func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
nonisolated func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
guard a.count == b.count else {
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
return 0.0
+3 -3
View File
@@ -29,7 +29,7 @@ import CryptoKit
import IOKit
class EncryptionService {
static let shared = EncryptionService()
nonisolated static let shared = EncryptionService()
private let salt = "oAI-secure-storage-v1" // App-specific salt
private lazy var encryptionKey: SymmetricKey = {
@@ -41,7 +41,7 @@ class EncryptionService {
// MARK: - Public Interface
/// Encrypt a string value
func encrypt(_ value: String) throws -> String {
nonisolated func encrypt(_ value: String) throws -> String {
guard let data = value.data(using: .utf8) else {
throw EncryptionError.invalidInput
}
@@ -55,7 +55,7 @@ class EncryptionService {
}
/// Decrypt a string value
func decrypt(_ encryptedValue: String) throws -> String {
nonisolated func decrypt(_ encryptedValue: String) throws -> String {
guard let data = Data(base64Encoded: encryptedValue) else {
throw EncryptionError.invalidInput
}
+1 -1
View File
@@ -212,7 +212,7 @@ class GitSyncService {
// Check if conversation already exists (by ID)
if let existingId = UUID(uuidString: export.id) {
if let existing = try? db.loadConversation(id: existingId) {
if (try? db.loadConversation(id: existingId)) != nil {
// Already exists - skip
log.debug("Skipping existing conversation: \(export.name)")
skipped += 1
+156
View File
@@ -0,0 +1,156 @@
//
// JarvisService.swift
// oAI
//
// HTTP client for the Jarvis (oAI-Web) REST API.
// Auth: Authorization: Bearer <api-key>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import Foundation
import os
final class JarvisService: Sendable {
static let shared = JarvisService()
private init() {}
private let log = Logger(subsystem: "com.oai.oAI", category: "jarvis")
private var baseURL: String { SettingsService.shared.jarvisURL }
private var apiKey: String? { SettingsService.shared.jarvisAPIKey }
// MARK: - Agents
func listAgents() async throws -> [JarvisAgent] {
try await get("/api/agents")
}
func createAgent(_ input: JarvisAgentInput) async throws -> JarvisAgent {
try await post("/api/agents", body: input)
}
func updateAgent(id: String, _ input: JarvisAgentInput) async throws -> JarvisAgent {
try await put("/api/agents/\(id)", body: input)
}
func deleteAgent(id: String) async throws {
try await voidRequest("DELETE", path: "/api/agents/\(id)")
}
func toggleAgent(id: String) async throws -> JarvisAgent {
try await post("/api/agents/\(id)/toggle", body: Empty())
}
func runAgent(id: String) async throws {
try await voidRequest("POST", path: "/api/agents/\(id)/run")
}
func stopAgent(id: String) async throws {
try await voidRequest("POST", path: "/api/agents/\(id)/stop")
}
func agentRuns(id: String) async throws -> [JarvisAgentRun] {
try await get("/api/agents/\(id)/runs")
}
// MARK: - Usage
func usage() async throws -> [JarvisUsageStat] {
let data = try await rawData("GET", path: "/api/usage")
// API returns {summary:{...}, by_agent:[...], chat:{...}}
if let w = try? JSONDecoder().decode(JarvisUsageResponse.self, from: data) {
return w.byAgent ?? []
}
// Fallback: plain array
if let arr = try? JSONDecoder().decode([JarvisUsageStat].self, from: data) { return arr }
return []
}
func credits() async throws -> JarvisCreditsResponse {
try await get("/api/usage/openrouter-credits")
}
// MARK: - Control
func queueStatus() async throws -> JarvisQueueStatus {
try await get("/api/status")
}
func pauseAll() async throws {
try await voidRequest("POST", path: "/api/pause")
}
func resumeAll() async throws {
try await voidRequest("POST", path: "/api/resume")
}
// MARK: - Connection test
func testConnection() async -> Bool {
guard !baseURL.isEmpty, let key = apiKey, !key.isEmpty else { return false }
do {
let _: [JarvisAgent] = try await get("/api/agents")
return true
} catch {
log.warning("Jarvis connection test failed: \(error.localizedDescription)")
return false
}
}
// MARK: - HTTP core
private func get<T: Decodable>(_ path: String) async throws -> T {
let data = try await rawData("GET", path: path)
return try JSONDecoder().decode(T.self, from: data)
}
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
let data = try await rawData("POST", path: path, body: body)
return try JSONDecoder().decode(T.self, from: data)
}
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
let data = try await rawData("PUT", path: path, body: body)
return try JSONDecoder().decode(T.self, from: data)
}
private func voidRequest(_ method: String, path: String) async throws {
_ = try await rawData(method, path: path)
}
private func rawData<B: Encodable>(_ method: String, path: String, body: B? = nil as Empty?) async throws -> Data {
guard let url = URL(string: baseURL.trimmingCharacters(in: .whitespaces) + path) else {
throw JarvisError.invalidURL
}
guard let key = apiKey, !key.isEmpty else {
throw JarvisError.noAPIKey
}
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 30
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(body)
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw JarvisError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
let msg = (try? JSONDecoder().decode(JarvisErrBody.self, from: data))?.detail ?? "HTTP \(http.statusCode)"
log.error("Jarvis \(method) \(path)\(http.statusCode): \(msg)")
throw JarvisError.serverError(http.statusCode, msg)
}
return data
}
}
private struct Empty: Codable {}
private struct JarvisErrBody: Decodable { let detail: String? }
+84
View File
@@ -43,6 +43,7 @@ class SettingsService {
static let googleSearchEngineID = "googleSearchEngineID"
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
static let paperlessAPIToken = "paperlessAPIToken"
static let jarvisAPIKey = "jarvisAPIKey"
}
// Old keychain keys (for migration only)
@@ -299,6 +300,24 @@ class SettingsService {
}
}
/// Input bar height in points default 80
var inputBarHeight: Double {
get { cache["inputBarHeight"].flatMap(Double.init) ?? 80.0 }
set {
cache["inputBarHeight"] = String(newValue)
DatabaseService.shared.setSetting(key: "inputBarHeight", value: String(newValue))
}
}
/// Whether the sidebar is visible default true
var sidebarVisible: Bool {
get { cache["sidebarVisible"] != "false" }
set {
cache["sidebarVisible"] = String(newValue)
DatabaseService.shared.setSetting(key: "sidebarVisible", value: String(newValue))
}
}
// MARK: - MCP Permissions
var mcpCanWriteFiles: Bool {
@@ -430,6 +449,31 @@ class SettingsService {
}
}
// MARK: - Favorite Models
var favoriteModelIds: Set<String> {
get {
guard let json = cache["favoriteModelIds"],
let data = json.data(using: .utf8),
let ids = try? JSONDecoder().decode([String].self, from: data) else { return [] }
return Set(ids)
}
set {
let sorted = newValue.sorted()
if let data = try? JSONEncoder().encode(sorted),
let json = String(data: data, encoding: .utf8) {
cache["favoriteModelIds"] = json
DatabaseService.shared.setSetting(key: "favoriteModelIds", value: json)
}
}
}
func toggleFavoriteModel(_ id: String) {
var favs = favoriteModelIds
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
favoriteModelIds = favs
}
// MARK: - Anytype MCP Settings
var anytypeMcpEnabled: Bool {
@@ -475,6 +519,46 @@ class SettingsService {
return !key.isEmpty
}
// MARK: - Jarvis Settings
var jarvisEnabled: Bool {
get { cache["jarvisEnabled"] == "true" }
set {
cache["jarvisEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "jarvisEnabled", value: String(newValue))
}
}
var jarvisURL: String {
get { cache["jarvisURL"] ?? "" }
set {
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
cache.removeValue(forKey: "jarvisURL")
DatabaseService.shared.deleteSetting(key: "jarvisURL")
} else {
cache["jarvisURL"] = trimmed
DatabaseService.shared.setSetting(key: "jarvisURL", value: trimmed)
}
}
}
var jarvisAPIKey: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.jarvisAPIKey) }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.jarvisAPIKey, value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.jarvisAPIKey)
}
}
}
var jarvisConfigured: Bool {
guard let key = jarvisAPIKey else { return false }
return !jarvisURL.isEmpty && !key.isEmpty
}
// MARK: - Bash Execution Settings
var bashEnabled: Bool {
+30
View File
@@ -35,6 +35,11 @@ final class UpdateCheckService {
var updateAvailable: Bool = false
var latestVersion: String? = nil
var downloadURL: URL? = nil
// Manual check state drives the update alert in ContentView
var isCheckingManually: Bool = false
var manualCheckMessage: String? = nil
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
@@ -48,6 +53,24 @@ final class UpdateCheckService {
}
}
/// Manual check triggered from the Help menu. Non-blocking result surfaces via manualCheckMessage.
func checkForUpdatesManually() {
guard !isCheckingManually else { return }
isCheckingManually = true
Task.detached(priority: .background) {
await self.performCheck()
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
await MainActor.run {
if self.updateAvailable, let v = self.latestVersion {
self.manualCheckMessage = String(localized: "Version \(v) is available.")
} else {
self.manualCheckMessage = String(localized: "You're up to date (v\(current)).")
}
self.isCheckingManually = false
}
}
}
private func performCheck() async {
guard let url = URL(string: apiURL) else { return }
@@ -69,9 +92,16 @@ final class UpdateCheckService {
let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
// Extract direct DMG download URL from release assets
let dmgURL: URL? = (release["assets"] as? [[String: Any]])?
.first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") }
.flatMap { $0["browser_download_url"] as? String }
.flatMap { URL(string: $0) }
if isNewer(latestVer, than: currentVer) {
await MainActor.run {
self.latestVersion = latestVer
self.downloadURL = dmgURL
self.updateAvailable = true
}
}
@@ -60,10 +60,11 @@ extension Color {
static func providerColor(_ provider: Settings.Provider) -> Color {
switch provider {
case .openrouter: return Color(hex: "#7c3aed") // Purple
case .anthropic: return Color(hex: "#d4895a") // Orange
case .openai: return Color(hex: "#10a37f") // Green
case .ollama: return Color(hex: "#ffffff") // White
case .openrouter: return Color(hex: "#7c3aed") // Purple
case .anthropic: return Color(hex: "#d4895a") // Orange
case .openai: return Color(hex: "#10a37f") // Green
case .ollama: return Color(hex: "#ffffff") // White
case .appleOnDevice: return Color(hex: "#636366") // Apple grey
}
}
+20 -18
View File
@@ -114,41 +114,43 @@ final class FileLogger: @unchecked Sendable {
// MARK: - App Logger (wraps os.Logger + file)
struct AppLogger {
let osLogger: Logger
// os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are
// nonisolated FileLogger runs on its own serial queue, os.Logger dispatches to main actor.
struct AppLogger: Sendable {
let subsystem: String
let category: String
func debug(_ message: String) {
nonisolated func debug(_ message: String) {
FileLogger.shared.write(.debug, category: category, message: message)
osLogger.debug("\(message, privacy: .public)")
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).debug("\(message, privacy: .public)") }
}
func info(_ message: String) {
nonisolated func info(_ message: String) {
FileLogger.shared.write(.info, category: category, message: message)
osLogger.info("\(message, privacy: .public)")
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).info("\(message, privacy: .public)") }
}
func warning(_ message: String) {
nonisolated func warning(_ message: String) {
FileLogger.shared.write(.warning, category: category, message: message)
osLogger.warning("\(message, privacy: .public)")
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).warning("\(message, privacy: .public)") }
}
func error(_ message: String) {
nonisolated func error(_ message: String) {
FileLogger.shared.write(.error, category: category, message: message)
osLogger.error("\(message, privacy: .public)")
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") }
}
}
// MARK: - Log Namespace
enum Log {
private static let subsystem = "com.oai.oAI"
private nonisolated static let subsystem = "com.oai.oAI"
static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api")
static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database")
static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp")
static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings")
static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search")
static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui")
static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general")
nonisolated static let api = AppLogger(subsystem: subsystem, category: "api")
nonisolated static let db = AppLogger(subsystem: subsystem, category: "database")
nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp")
nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings")
nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
}
+43 -11
View File
@@ -53,6 +53,7 @@ class ChatViewModel {
var showHistory: Bool = false
var showShortcuts: Bool = false
var showSkills: Bool = false
var showJarvis: Bool = false
var modelInfoTarget: ModelInfo? = nil
var commandHistory: [String] = []
var historyIndex: Int = 0
@@ -417,12 +418,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
let newProvider = inferProvider(from: model.id) ?? currentProvider
selectedModel = model
currentProvider = newProvider
settings.defaultModel = model.id
settings.defaultProvider = newProvider
MCPService.shared.resetBashSessionApproval()
}
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
private func inferProvider(from modelId: String) -> Settings.Provider? {
// Apple Foundation Models
if modelId.hasPrefix("apple-") { return .appleOnDevice }
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
if modelId.contains("/") { return .openrouter }
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
@@ -742,6 +745,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
case "/skills":
showSkills = true
case "/jarvis":
showJarvis = true
case "/mcp":
handleMCPCommand(args: args)
@@ -1265,6 +1271,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
let mcp = MCPService.shared
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
isGenerating = true
streamingTask?.cancel()
@@ -1308,7 +1315,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Append the complete system prompt (default + custom)
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
var messagesToSend: [Message] = memoryEnabled
let messagesToSend: [Message] = memoryEnabled
? messages.filter { $0.role != .system }
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
@@ -1351,6 +1358,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit
var finalContent = ""
var finalImages: [Data] = []
var didContinueAfterImages = false // Only inject temp-file continuation once
var totalUsage: ChatResponse.Usage?
var hitIterationLimit = false // Track if we exited due to hitting the limit
@@ -1379,9 +1388,32 @@ Don't narrate future actions ("Let me...") - just use the tools.
let toolCalls = structuredCalls.isEmpty ? textCalls : structuredCalls
guard !toolCalls.isEmpty else {
// No tool calls this is the final text response
// Strip any unparseable tool call text from display
// No tool calls this is the final response
finalContent = response.content
if let images = response.generatedImages { finalImages = images }
Log.ui.debug("Tools final response: content='\(response.content.prefix(80))', images=\(response.generatedImages?.count ?? 0)")
// If images were generated and tools are available, save to temp files
// and continue the loop so the model can save them to the requested path.
if !finalImages.isEmpty && !didContinueAfterImages && iteration < maxIterations - 1 {
didContinueAfterImages = true
let timestamp = Int(Date().timeIntervalSince1970)
let tempPaths: [String] = finalImages.enumerated().compactMap { i, imgData in
let path = "/tmp/oai_generated_\(timestamp)_\(i).png"
let ok = FileManager.default.createFile(atPath: path, contents: imgData)
Log.ui.debug("Saved generated image to temp: \(path) ok=\(ok)")
return ok ? path : nil
}
if !tempPaths.isEmpty {
let pathList = tempPaths.joined(separator: ", ")
let assistantContent = response.content.isEmpty ? "[Image generated]" : response.content
apiMessages.append(["role": "assistant", "content": assistantContent])
apiMessages.append(["role": "user", "content": "The image(s) have been generated and temporarily saved to: \(pathList). Please save them to the requested destination(s) using the available tools (bash or MCP write)."])
finalImages = []
finalContent = ""
continue
}
}
break
}
@@ -1491,7 +1523,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
attachments: nil,
responseTime: responseTime,
wasInterrupted: wasCancelled,
modelId: modelId
modelId: modelId,
generatedImages: finalImages.isEmpty ? nil : finalImages
)
messages.append(assistantMessage)
@@ -1628,7 +1661,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
let delay = Double(1 << attempt) // 2s, 4s, 8s
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
await MainActor.run {
_ = await MainActor.run {
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
}
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
@@ -1935,12 +1968,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
func detectGoodbyePhrase(in text: String) -> Bool {
let lowercased = text.lowercased()
let goodbyePhrases = [
"bye", "goodbye", "bye bye",
"thanks", "thank you", "thx", "ty",
"bye", "goodbye", "bye bye", "good bye",
"that's all", "thats all", "that'll be all",
"done", "i'm done", "we're done",
"i'm done", "we're done",
"see you", "see ya", "catch you later",
"have a good", "have a nice"
"have a good day", "have a nice day"
]
return goodbyePhrases.contains { phrase in
+11 -7
View File
@@ -37,10 +37,6 @@ struct ChatView: View {
HeaderView(
provider: viewModel.currentProvider,
model: viewModel.selectedModel,
stats: viewModel.sessionStats,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled,
mcpStatus: viewModel.mcpStatus,
onModelSelect: onModelSelect,
onProviderChange: onProviderChange
)
@@ -85,10 +81,13 @@ struct ChatView: View {
InputBar(
text: $viewModel.inputText,
isGenerating: viewModel.isGenerating,
mcpStatus: viewModel.mcpStatus,
onlineMode: viewModel.onlineMode,
onSend: viewModel.sendMessage,
onCancel: viewModel.cancelGeneration
onCancel: viewModel.cancelGeneration,
onToggleOnline: {
viewModel.onlineMode.toggle()
SettingsService.shared.onlineMode = viewModel.onlineMode
}
)
// Footer
@@ -96,7 +95,9 @@ struct ChatView: View {
stats: viewModel.sessionStats,
conversationName: viewModel.currentConversationName,
hasUnsavedChanges: viewModel.hasUnsavedChanges,
onQuickSave: viewModel.quickSave
onQuickSave: viewModel.quickSave,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled
)
}
.background(Color.oaiBackground)
@@ -106,6 +107,9 @@ struct ChatView: View {
.sheet(isPresented: $viewModel.showSkills) {
AgentSkillsView()
}
.sheet(isPresented: $viewModel.showJarvis) {
JarvisView()
}
.sheet(item: Binding(
get: { MCPService.shared.pendingBashCommand },
set: { _ in }
+56 -106
View File
@@ -2,7 +2,7 @@
// ContentView.swift
// oAI
//
// Root navigation container
// Root navigation container NavigationSplitView with collapsible sidebar
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
@@ -24,30 +24,34 @@
import SwiftUI
#if os(macOS)
import Darwin // uname, sysctlbyname
#endif
struct ContentView: View {
@Environment(ChatViewModel.self) var chatViewModel
private var updateService = UpdateCheckService.shared
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var showIntelWarning = false
var body: some View {
@Bindable var vm = chatViewModel
NavigationStack {
NavigationSplitView(columnVisibility: $columnVisibility) {
SidebarView()
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
} detail: {
ChatView(
onModelSelect: { chatViewModel.showModelSelector = true },
onProviderChange: { newProvider in
chatViewModel.changeProvider(newProvider)
}
)
.navigationTitle("")
.toolbar {
#if os(macOS)
macOSToolbar
#endif
}
}
.frame(minWidth: 640, minHeight: 400)
.frame(minWidth: 860, minHeight: 560)
#if os(macOS)
.onAppear {
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
checkIntelWarning()
}
.onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.command) {
@@ -65,7 +69,6 @@ struct ContentView: View {
let oldModel = chatViewModel.selectedModel
chatViewModel.selectModel(model)
chatViewModel.showModelSelector = false
// Trigger auto-save on model switch
Task {
await chatViewModel.onModelSwitch(from: oldModel, to: model)
}
@@ -113,109 +116,56 @@ struct ContentView: View {
chatViewModel.inputText = input
})
}
.alert("Intel Mac Support Ending", isPresented: $showIntelWarning) {
Button("Got It") {
UserDefaults.standard.set(true, forKey: "hasShownIntelWarning")
}
} message: {
Text("oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.")
}
.alert("Software Update", isPresented: Binding(
get: { updateService.manualCheckMessage != nil },
set: { if !$0 { updateService.manualCheckMessage = nil } }
)) {
if updateService.updateAvailable {
if let url = updateService.downloadURL {
Button("Download v\(updateService.latestVersion ?? "")") {
NSWorkspace.shared.open(url)
}
}
Button("Release Page") { updateService.openReleasesPage() }
Button("Later", role: .cancel) { }
} else {
Button("OK", role: .cancel) { }
}
} message: {
Text(updateService.manualCheckMessage ?? "")
}
}
#if os(macOS)
@ToolbarContentBuilder
private var macOSToolbar: some ToolbarContent {
let settings = SettingsService.shared
let showLabels = settings.showToolbarLabels
let scale = iconScale(for: settings.toolbarIconSize)
private func checkIntelWarning() {
guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
guard isIntelNative || isRosetta else { return }
showIntelWarning = true
}
ToolbarItemGroup(placement: .automatic) {
// New conversation
Button(action: { chatViewModel.newConversation() }) {
ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("n", modifiers: .command)
.help("New conversation")
Button(action: { chatViewModel.showConversations = true }) {
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("l", modifiers: .command)
.help("Saved conversations (Cmd+L)")
Button(action: { chatViewModel.showHistory = true }) {
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("h", modifiers: .command)
.help("Command history (Cmd+H)")
Spacer()
Button(action: { chatViewModel.showModelSelector = true }) {
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, scale: scale)
}
.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, scale: scale)
}
.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, scale: scale)
}
.help("Session statistics")
Button(action: { chatViewModel.showCredits = true }) {
ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, scale: scale)
}
.help("Check API credits")
Spacer()
Button(action: { chatViewModel.showSettings = true }) {
ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, scale: scale)
}
.keyboardShortcut(",", modifiers: .command)
.help("Settings (Cmd+,)")
Button(action: { chatViewModel.showHelp = true }) {
ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, scale: scale)
}
.keyboardShortcut("/", modifiers: .command)
.help("Help & commands (Cmd+/)")
private var isIntelNative: Bool {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) {
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
}
return machine.contains("x86_64")
}
private var isRosetta: Bool {
var ret: Int32 = 0
var size = MemoryLayout<Int32>.size
sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
return ret == 1
}
#endif
// Helper function to convert icon size to imageScale
private func iconScale(for size: Double) -> Image.Scale {
switch size {
case ...18: return .small
case 19...24: return .medium
default: return .large
}
}
}
// Helper view for toolbar labels
struct ToolbarLabel: View {
let title: LocalizedStringKey
let systemImage: String
let showLabels: Bool
let scale: Image.Scale
var body: some View {
if showLabels {
Label(title, systemImage: systemImage)
.labelStyle(.titleAndIcon)
.imageScale(scale)
} else {
Label(title, systemImage: systemImage)
.labelStyle(.iconOnly)
.imageScale(scale)
}
}
}
#Preview {
+24 -13
View File
@@ -30,15 +30,22 @@ struct FooterView: View {
let conversationName: String?
let hasUnsavedChanges: Bool
let onQuickSave: (() -> Void)?
let onlineMode: Bool
let mcpEnabled: Bool
private let settings = SettingsService.shared
init(stats: SessionStats,
conversationName: String? = nil,
hasUnsavedChanges: Bool = false,
onQuickSave: (() -> Void)? = nil) {
onQuickSave: (() -> Void)? = nil,
onlineMode: Bool = false,
mcpEnabled: Bool = false) {
self.stats = stats
self.conversationName = conversationName
self.hasUnsavedChanges = hasUnsavedChanges
self.onQuickSave = onQuickSave
self.onlineMode = onlineMode
self.mcpEnabled = mcpEnabled
}
var body: some View {
@@ -71,6 +78,21 @@ struct FooterView: View {
Spacer()
// Status pills Online, MCP, Sync
#if os(macOS)
HStack(spacing: 6) {
if onlineMode {
StatusPill(icon: "globe", label: "Online", color: .green)
}
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
if settings.syncEnabled && settings.syncAutoSave {
SyncStatusPill()
}
}
#endif
// Save indicator (only when chat has messages)
if stats.messageCount > 0 {
SaveIndicator(
@@ -80,17 +102,11 @@ struct FooterView: View {
)
}
// Update available badge
// Update available badge (shows only when an update exists no version number)
#if os(macOS)
UpdateBadge()
#endif
// Shortcuts hint
#if os(macOS)
Text("⌘N New • ⌘M Model • ⌘S Save")
.font(.caption2)
.foregroundColor(.oaiSecondary)
#endif
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
@@ -242,7 +258,6 @@ struct SyncStatusFooter: View {
struct UpdateBadge: View {
private let updater = UpdateCheckService.shared
private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
var body: some View {
if updater.updateAvailable {
@@ -258,10 +273,6 @@ struct UpdateBadge: View {
}
.buttonStyle(.plain)
.help("A new version is available — click to open the releases page")
} else {
Text("v\(currentVersion)")
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
}
}
+17 -65
View File
@@ -2,7 +2,8 @@
// HeaderView.swift
// oAI
//
// Header bar with provider, model, and stats
// Slim header provider, model name, star only.
// Status pills and stats live in SidebarView and FooterView respectively.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
@@ -28,15 +29,10 @@ import SwiftUI
struct HeaderView: View {
let provider: Settings.Provider
let model: ModelInfo?
let stats: SessionStats
let onlineMode: Bool
let mcpEnabled: Bool
let mcpStatus: String?
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
private let settings = SettingsService.shared
private let registry = ProviderRegistry.shared
private let gitSync = GitSyncService.shared
var body: some View {
HStack(spacing: 12) {
@@ -75,7 +71,7 @@ struct HeaderView: View {
.fixedSize()
.help("Switch provider")
// Model info (clickable model selector)
// Model name (clickable model selector)
Button(action: onModelSelect) {
if let model = model {
HStack(spacing: 6) {
@@ -116,7 +112,6 @@ struct HeaderView: View {
Text("No model selected")
.font(.system(size: settings.guiTextSize))
.foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
@@ -126,41 +121,22 @@ struct HeaderView: View {
.buttonStyle(.plain)
.help("Select model")
// Favourite star
if let model = model {
let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) {
Image(systemName: isFav ? "star.fill" : "star")
.font(.system(size: settings.guiTextSize - 3))
.foregroundColor(isFav ? .yellow : .oaiSecondary)
}
.buttonStyle(.plain)
.help(isFav ? "Remove from favorites" : "Add to favorites")
}
Spacer()
// 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)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
@@ -171,22 +147,7 @@ struct HeaderView: View {
}
}
struct StatItem: View {
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)
}
}
}
// MARK: - Status Pills (used by SidebarView)
struct StatusPill: View {
let icon: String
@@ -273,15 +234,6 @@ struct SyncStatusPill: View {
HeaderView(
provider: .openrouter,
model: ModelInfo.mockModels.first,
stats: SessionStats(
totalInputTokens: 125,
totalOutputTokens: 434,
totalCost: 0.00111,
messageCount: 4
),
onlineMode: true,
mcpEnabled: true,
mcpStatus: "MCP",
onModelSelect: {},
onProviderChange: { _ in }
)
+97 -96
View File
@@ -2,7 +2,7 @@
// InputBar.swift
// oAI
//
// Message input bar with status indicators
// Message input bar with resizable height and online toggle
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
@@ -24,24 +24,35 @@
import SwiftUI
#if os(macOS)
import AppKit
#endif
struct InputBar: View {
@Binding var text: String
let isGenerating: Bool
let mcpStatus: String?
let onlineMode: Bool
let onSend: () -> Void
let onCancel: () -> Void
let onToggleOnline: () -> Void
private let settings = SettingsService.shared
// Resizable input height persisted to settings
@State private var inputHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
@State private var dragStartHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
@State private var showCommandDropdown = false
@State private var selectedSuggestionIndex: Int = 0
@FocusState private var isInputFocused: Bool
private static let minInputHeight: CGFloat = 56
private static let maxInputHeight: CGFloat = 320
/// Commands that execute immediately without additional arguments
private static let immediateCommands: Set<String> = [
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
"/settings", "/credits", "/list", "/load", "/shortcuts", "/skills",
"/settings", "/credits", "/list", "/load", "/shortcuts", "/skills", "/jarvis",
"/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
"/mcp write on", "/mcp write off",
@@ -56,48 +67,42 @@ struct InputBar: View {
CommandSuggestionsView(
searchText: text,
selectedIndex: selectedSuggestionIndex,
onSelect: { command in
selectCommand(command)
}
onSelect: selectCommand
)
.frame(width: 400)
.frame(maxHeight: 200)
.transition(.move(edge: .bottom).combined(with: .opacity))
Spacer()
}
.padding(.leading, 96) // Align with input box (status badges + spacing)
.padding(.leading, 16)
}
// Input area
HStack(alignment: .bottom, spacing: 12) {
// Status indicators
HStack(spacing: 6) {
if let mcp = mcpStatus {
StatusBadge(text: mcp, color: .blue)
}
if onlineMode {
StatusBadge(text: "🌐", color: .green)
}
}
.frame(width: 80, alignment: .leading)
// Drag-to-resize handle
dragHandle
// Text input
// Input row
HStack(alignment: .bottom, spacing: 12) {
// Text input with globe toggle in bottom-left corner
ZStack(alignment: .topLeading) {
// Placeholder
if text.isEmpty {
Text("Type a message or / for commands...")
.font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.padding(.top, 10)
.allowsHitTesting(false)
}
// Editor fills the fixed-height box, bottom area reserved for globe
TextEditor(text: $text)
.font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiPrimary)
.scrollContentBackground(.hidden)
.frame(minHeight: 44, maxHeight: 120)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.padding(.top, 6)
.padding(.bottom, 30)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.focused($isInputFocused)
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
@@ -105,7 +110,6 @@ struct InputBar: View {
}
#if os(macOS)
.onKeyPress(.upArrow) {
// Navigate command dropdown
if showCommandDropdown && selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
return .handled
@@ -113,7 +117,6 @@ struct InputBar: View {
return .ignored
}
.onKeyPress(.downArrow) {
// Navigate command dropdown
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
@@ -124,25 +127,12 @@ struct InputBar: View {
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
}
if showCommandDropdown { showCommandDropdown = false; return .handled }
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 press.modifiers.contains(.shift) { return .ignored }
if showCommandDropdown {
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
@@ -150,16 +140,30 @@ struct InputBar: View {
return .handled
}
}
// Return (plain or with Cmd): send message
if !text.isEmpty {
onSend()
return .handled
}
// Empty text: do nothing
if !text.isEmpty { onSend(); return .handled }
return .handled
}
#endif
// 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)
.cornerRadius(8)
.overlay(
@@ -167,10 +171,9 @@ struct InputBar: View {
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
)
// Action buttons
// Send / stop + attach buttons
VStack(spacing: 8) {
#if os(macOS)
// File attach button
Button(action: pickFile) {
Image(systemName: "paperclip")
.font(.title2)
@@ -209,21 +212,47 @@ struct InputBar: View {
}
}
// MARK: - Drag handle
private var dragHandle: some View {
Color.clear
.frame(height: 8)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.overlay {
Capsule()
.fill(Color.secondary.opacity(0.25))
.frame(width: 36, height: 3)
}
.gesture(
DragGesture(minimumDistance: 1)
.onChanged { value in
let proposed = dragStartHeight - value.translation.height
inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed))
}
.onEnded { _ in
dragStartHeight = inputHeight
settings.inputBarHeight = Double(inputHeight)
}
)
#if os(macOS)
.onHover { hovering in
if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
}
#endif
}
// MARK: - Helpers
private func selectCommand(_ command: String) {
showCommandDropdown = false
if Self.immediateCommands.contains(command) {
// Execute immediately
text = command
onSend()
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
if shortcut.needsInput {
text = command + " "
} else {
text = command
onSend()
}
text = shortcut.needsInput ? command + " " : command
if !shortcut.needsInput { onSend() }
} else {
// Put in input for user to complete
text = command + " "
}
}
@@ -235,36 +264,14 @@ struct InputBar: View {
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.message = "Select files to attach"
guard panel.runModal() == .OK else { return }
let paths = panel.urls.map { $0.path }
// Use @<path> format (angle brackets) to safely handle paths with spaces
let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ")
if text.isEmpty {
text = attachmentText + " "
} else {
text += " " + attachmentText
}
let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ")
text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText
}
#endif
}
struct StatusBadge: View {
let text: String
let color: Color
var body: some View {
Text(text)
.font(.caption)
.foregroundColor(color)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.cornerRadius(4)
}
}
// MARK: - Command suggestions
struct CommandSuggestionsView: View {
let searchText: String
@@ -279,6 +286,7 @@ struct CommandSuggestionsView: View {
("/retry", "Retry last message"),
("/shortcuts", "Manage your prompt shortcuts"),
("/skills", "Manage your agent skills"),
("/jarvis", "Open Jarvis agent manager"),
("/memory on", "Enable conversation memory"),
("/memory off", "Disable conversation memory"),
("/online on", "Enable web search"),
@@ -303,10 +311,9 @@ struct CommandSuggestionsView: View {
]
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
let shortcuts = SettingsService.shared.userShortcuts.map { s in
SettingsService.shared.userShortcuts.map { s in
(s.command, LocalizedStringKey("\(s.description)"))
}
return builtInCommands + shortcuts
} + builtInCommands
}
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
@@ -343,26 +350,20 @@ struct CommandSuggestionsView: View {
.id(suggestion.command)
if index < suggestions.count - 1 {
Divider()
.background(Color.oaiBorder)
Divider().background(Color.oaiBorder)
}
}
}
}
.onChange(of: selectedIndex) {
if selectedIndex < suggestions.count {
withAnimation {
proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
}
withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) }
}
}
}
.background(Color.oaiSurface)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.oaiBorder, lineWidth: 1)
)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1))
}
}
@@ -372,10 +373,10 @@ struct CommandSuggestionsView: View {
InputBar(
text: .constant(""),
isGenerating: false,
mcpStatus: "📁 Files",
onlineMode: true,
onSend: {},
onCancel: {}
onCancel: {},
onToggleOnline: {}
)
}
.background(Color.oaiBackground)
+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)
}
+11
View File
@@ -80,6 +80,17 @@ struct CreditsView: View {
.font(.system(size: 40))
.foregroundColor(.green)
.padding(.top)
case .appleOnDevice:
Text("Apple Intelligence")
.font(.headline)
Text("On-device and free — no credits or API key needed.")
.font(.body)
.foregroundColor(.secondary)
Image(systemName: "apple.logo")
.font(.system(size: 40))
.foregroundColor(.secondary)
.padding(.top)
}
}
+8
View File
@@ -180,6 +180,14 @@ private let helpCategories: [CommandCategory] = [
examples: ["/mcp write on", "/mcp write off"]
),
]),
CommandCategory(name: "Integrations", icon: "server.rack", commands: [
CommandDetail(
command: "/jarvis",
brief: "Open Jarvis agent manager",
detail: "Opens the Jarvis panel where you can list, create, run, and monitor agents on your Jarvis (oAI-Web) server, and view usage and cost statistics. Configure the server URL and API key in Settings → Jarvis.",
examples: ["/jarvis"]
),
]),
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
CommandDetail(
command: "/config",
+866
View File
@@ -0,0 +1,866 @@
//
// JarvisView.swift
// oAI
//
// Main modal for managing Jarvis (oAI-Web) agents and usage.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import SwiftUI
// MARK: - Editor context (avoids .sheet timing bug)
private struct AgentEditContext: Identifiable {
let id = UUID()
let agent: JarvisAgent? // nil new
}
// MARK: - JarvisView
struct JarvisView: View {
@Environment(\.dismiss) var dismiss
@State private var selectedTab = 0
@State private var agents: [JarvisAgent] = []
@State private var runs: [String: [JarvisAgentRun]] = [:]
@State private var usageStats: [JarvisUsageStat] = []
@State private var credits: JarvisCreditsResponse? = nil
@State private var queueStatus: JarvisQueueStatus? = nil
@State private var isLoadingAgents = false
@State private var isLoadingUsage = false
@State private var selectedAgent: JarvisAgent? = nil
@State private var editContext: AgentEditContext? = nil
@State private var errorMessage: String? = nil
@State private var actionInProgress: Set<String> = []
private let service = JarvisService.shared
var body: some View {
VStack(spacing: 0) {
// Header
HStack(alignment: .center) {
Label("Jarvis", systemImage: "server.rack")
.font(.system(size: 18, weight: .bold))
Spacer()
if let qs = queueStatus {
queueStatusBadge(qs)
}
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2).foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 10)
// Tab picker
Picker("", selection: $selectedTab) {
Text("Agents").tag(0)
Text("Usage").tag(1)
}
.pickerStyle(.segmented)
.padding(.horizontal, 24)
.padding(.bottom, 10)
Divider()
if let err = errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text(err).font(.callout).foregroundStyle(.secondary)
Spacer()
Button("Dismiss") { errorMessage = nil }.buttonStyle(.borderless)
}
.padding(.horizontal, 24)
.padding(.vertical, 8)
Divider()
}
switch selectedTab {
case 0: agentsTab
default: usageTab
}
}
.frame(minWidth: 840, idealWidth: 960, minHeight: 560, idealHeight: 720)
.task { await loadAll() }
.sheet(item: $editContext) { ctx in
JarvisAgentEditorSheet(agent: ctx.agent, onSave: { input in
await saveAgent(existing: ctx.agent, input: input)
})
}
}
// MARK: - Agents Tab
private var agentsTab: some View {
NavigationSplitView {
agentList
} detail: {
if let agent = selectedAgent {
agentDetailView(agent)
} else {
ContentUnavailableView("Select an Agent", systemImage: "server.rack",
description: Text("Choose an agent from the list to view details and run history"))
}
}
.navigationSplitViewStyle(.balanced)
}
private var agentList: some View {
VStack(spacing: 0) {
// Toolbar
HStack(spacing: 8) {
Button {
editContext = AgentEditContext(agent: nil)
} label: {
Label("New Agent", systemImage: "plus")
}
.buttonStyle(.bordered)
.controlSize(.small)
Spacer()
if isLoadingAgents {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
}
Button { Task { await loadAgents() } } label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Refresh agents")
// Queue control
if let qs = queueStatus {
Button {
Task {
do {
if qs.paused == true { try await service.resumeAll() }
else { try await service.pauseAll() }
await loadQueueStatus()
} catch { errorMessage = error.localizedDescription }
}
} label: {
Image(systemName: qs.paused == true ? "play.fill" : "pause.fill")
}
.buttonStyle(.borderless)
.help(qs.paused == true ? "Resume queue" : "Pause queue")
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
Divider()
if agents.isEmpty && !isLoadingAgents {
ContentUnavailableView("No Agents", systemImage: "server.rack")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(agents, id: \.id, selection: $selectedAgent) { agent in
agentRow(agent)
.tag(agent)
}
.listStyle(.plain)
}
}
}
private func agentRow(_ agent: JarvisAgent) -> some View {
HStack(spacing: 8) {
// Status dot
Circle()
.fill(agent.isRunning == true ? Color.green : Color.secondary.opacity(0.4))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(agent.name)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
if let schedule = agent.schedule, !schedule.isEmpty {
Text(schedule)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// Enable toggle
Toggle("", isOn: Binding(
get: { agent.enabled },
set: { _ in Task { await toggleAgent(agent) } }
))
.toggleStyle(.switch)
.controlSize(.mini)
.labelsHidden()
// Run / Stop button
if agent.isRunning == true {
Button {
Task { await stopAgent(agent) }
} label: {
Image(systemName: "stop.fill")
.font(.system(size: 11))
.foregroundStyle(.red)
}
.buttonStyle(.borderless)
.disabled(actionInProgress.contains(agent.id))
.help("Stop agent")
} else {
Button {
Task { await runAgent(agent) }
} label: {
Image(systemName: actionInProgress.contains(agent.id) ? "ellipsis" : "play.fill")
.font(.system(size: 11))
.foregroundStyle(.green)
}
.buttonStyle(.borderless)
.disabled(actionInProgress.contains(agent.id) || !agent.enabled)
.help("Run agent now")
}
}
.padding(.vertical, 2)
.contentShape(Rectangle())
}
@ViewBuilder
private func agentDetailView(_ agent: JarvisAgent) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Agent info header
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Circle()
.fill(agent.isRunning == true ? Color.green : Color.secondary.opacity(0.4))
.frame(width: 10, height: 10)
Text(agent.name)
.font(.system(size: 16, weight: .semibold))
if !agent.enabled {
Text("Disabled")
.font(.caption)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.orange.opacity(0.15))
.foregroundStyle(.orange)
.cornerRadius(4)
}
}
Text(agent.model)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
editContext = AgentEditContext(agent: agent)
} label: {
Label("Edit", systemImage: "pencil")
}
.buttonStyle(.bordered)
.controlSize(.small)
Button(role: .destructive) {
Task { await deleteAgent(agent) }
} label: {
Image(systemName: "trash")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
if !agent.description.isEmpty {
Text(agent.description)
.font(.callout)
.foregroundStyle(.secondary)
}
if let schedule = agent.schedule, !schedule.isEmpty {
Label(schedule, systemImage: "clock")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(16)
Divider()
// Prompt preview
DisclosureGroup("Prompt") {
ScrollView {
Text(agent.prompt)
.font(.system(size: 12, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.padding(8)
}
.frame(maxHeight: 120)
.background(Color.gray.opacity(0.06))
.cornerRadius(6)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
.padding(.horizontal, 16)
.padding(.top, 8)
Divider()
// Run history
Text("Run History")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 4)
let agentRuns = runs[agent.id] ?? []
if agentRuns.isEmpty {
Text("No runs yet")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} else {
List(agentRuns) { run in
RunHistoryRow(run: run)
}
.listStyle(.plain)
}
}
.task(id: agent.id) {
await loadRuns(for: agent)
}
}
// MARK: - Usage Tab
private var usageTab: some View {
VStack(spacing: 0) {
// Credits card
if let creds = credits, let balance = creds.remainingBalance {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("OpenRouter Balance")
.font(.caption)
.foregroundStyle(.secondary)
Text(String(format: "$%.4f", balance))
.font(.system(size: 22, weight: .semibold, design: .monospaced))
.foregroundStyle(balance < 1.0 ? .orange : .primary)
}
Spacer()
if let total = creds.totalCredits {
VStack(alignment: .trailing, spacing: 4) {
Text("Total Credits")
.font(.caption).foregroundStyle(.secondary)
Text(String(format: "$%.2f", total))
.font(.callout).foregroundStyle(.secondary)
}
}
if let used = creds.totalUsage {
VStack(alignment: .trailing, spacing: 4) {
Text("Total Used")
.font(.caption).foregroundStyle(.secondary)
Text(String(format: "$%.4f", used))
.font(.callout).foregroundStyle(.secondary)
}
}
}
.padding(16)
.background(Color.blue.opacity(0.06))
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
}
if isLoadingUsage {
ProgressView("Loading usage…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if usageStats.isEmpty {
ContentUnavailableView("No Usage Data", systemImage: "chart.bar",
description: Text("Run some agents to see usage statistics"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
// Header row
HStack {
Text("Agent")
.frame(maxWidth: .infinity, alignment: .leading)
Text("Runs")
.frame(width: 50, alignment: .trailing)
Text("Input")
.frame(width: 80, alignment: .trailing)
Text("Output")
.frame(width: 80, alignment: .trailing)
Text("Cost")
.frame(width: 80, alignment: .trailing)
}
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.padding(.vertical, 6)
Divider()
ScrollView {
VStack(spacing: 0) {
ForEach(usageStats) { stat in
usageRow(stat)
Divider().padding(.leading, 16)
}
// Totals row
let totalRuns = usageStats.compactMap(\.runCount).reduce(0, +)
let totalInput = usageStats.compactMap(\.totalInputTokens).reduce(0, +)
let totalOutput = usageStats.compactMap(\.totalOutputTokens).reduce(0, +)
let totalCost = usageStats.compactMap(\.totalCostUsd).reduce(0, +)
HStack {
Text("Total")
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(totalRuns)")
.frame(width: 50, alignment: .trailing)
.font(.system(size: 13, weight: .semibold))
Text(formatTokens(totalInput))
.frame(width: 80, alignment: .trailing)
.font(.system(size: 13, weight: .semibold))
Text(formatTokens(totalOutput))
.frame(width: 80, alignment: .trailing)
.font(.system(size: 13, weight: .semibold))
Text(String(format: "$%.4f", totalCost))
.frame(width: 80, alignment: .trailing)
.font(.system(size: 13, weight: .semibold, design: .monospaced))
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color.gray.opacity(0.07))
}
}
HStack {
Spacer()
Button { Task { await loadUsage() } } label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderless)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
}
}
private func usageRow(_ stat: JarvisUsageStat) -> some View {
HStack {
Text(stat.displayName)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
Text("\(stat.runCount ?? 0)")
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Text(formatTokens(stat.totalInputTokens ?? 0))
.frame(width: 80, alignment: .trailing)
.foregroundStyle(.secondary)
Text(formatTokens(stat.totalOutputTokens ?? 0))
.frame(width: 80, alignment: .trailing)
.foregroundStyle(.secondary)
Text(stat.totalCostUsd.map { String(format: "$%.4f", $0) } ?? "")
.frame(width: 80, alignment: .trailing)
.font(.system(size: 13, design: .monospaced))
}
.font(.system(size: 13))
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
// MARK: - Queue Status Badge
private func queueStatusBadge(_ qs: JarvisQueueStatus) -> some View {
HStack(spacing: 6) {
if qs.paused == true {
Label("Paused", systemImage: "pause.fill")
.foregroundStyle(.orange)
} else if let running = qs.runningCount, running > 0 {
Label("^[\(running) running](inflect: true)", systemImage: "circle.fill")
.foregroundStyle(.green)
} else {
Label("Idle", systemImage: "checkmark.circle")
.foregroundStyle(.secondary)
}
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.1))
.cornerRadius(6)
}
// MARK: - Data Loading
private func loadAll() async {
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.loadAgents() }
group.addTask { await self.loadUsage() }
group.addTask { await self.loadQueueStatus() }
}
}
private func loadAgents() async {
isLoadingAgents = true
do {
let fetched = try await service.listAgents()
agents = fetched
if let current = selectedAgent {
selectedAgent = fetched.first(where: { $0.id == current.id })
}
} catch {
errorMessage = error.localizedDescription
}
isLoadingAgents = false
}
private func loadRuns(for agent: JarvisAgent) async {
do {
let fetched = try await service.agentRuns(id: agent.id)
runs[agent.id] = fetched
} catch {
errorMessage = error.localizedDescription
}
}
private func loadUsage() async {
isLoadingUsage = true
do {
async let statsTask = service.usage()
async let creditsTask = service.credits()
usageStats = try await statsTask
credits = try? await creditsTask
} catch {
errorMessage = error.localizedDescription
}
isLoadingUsage = false
}
private func loadQueueStatus() async {
queueStatus = try? await service.queueStatus()
}
// MARK: - Agent Actions
private func toggleAgent(_ agent: JarvisAgent) async {
do {
let updated = try await service.toggleAgent(id: agent.id)
updateAgent(updated)
} catch {
errorMessage = error.localizedDescription
}
}
private func runAgent(_ agent: JarvisAgent) async {
actionInProgress.insert(agent.id)
do {
try await service.runAgent(id: agent.id)
try? await Task.sleep(for: .seconds(1))
await loadAgents()
} catch {
errorMessage = error.localizedDescription
}
actionInProgress.remove(agent.id)
}
private func stopAgent(_ agent: JarvisAgent) async {
do {
try await service.stopAgent(id: agent.id)
try? await Task.sleep(for: .seconds(1))
await loadAgents()
} catch {
errorMessage = error.localizedDescription
}
}
private func deleteAgent(_ agent: JarvisAgent) async {
do {
try await service.deleteAgent(id: agent.id)
agents.removeAll { $0.id == agent.id }
if selectedAgent?.id == agent.id { selectedAgent = nil }
} catch {
errorMessage = error.localizedDescription
}
}
private func saveAgent(existing: JarvisAgent?, input: JarvisAgentInput) async {
do {
let result: JarvisAgent
if let existing {
result = try await service.updateAgent(id: existing.id, input)
} else {
result = try await service.createAgent(input)
}
updateAgent(result)
selectedAgent = result
} catch {
errorMessage = error.localizedDescription
}
}
private func updateAgent(_ updated: JarvisAgent) {
if let idx = agents.firstIndex(where: { $0.id == updated.id }) {
agents[idx] = updated
} else {
agents.append(updated)
}
if selectedAgent?.id == updated.id {
selectedAgent = updated
}
}
// MARK: - Formatting
private func formatTokens(_ n: Int) -> String {
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
if n >= 1_000 { return String(format: "%.1fK", Double(n) / 1_000) }
return "\(n)"
}
}
// MARK: - Run History Row
private struct RunHistoryRow: View {
let run: JarvisAgentRun
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
statusIcon
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text(run.formattedStarted)
.font(.system(size: 12))
if let dur = run.formattedDuration {
Text("· \(dur)")
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
}
HStack(spacing: 8) {
if run.totalTokens > 0 {
Text("^[\(run.totalTokens) token](inflect: true)")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let cost = run.costUsd, cost > 0 {
Text(String(format: "$%.5f", cost))
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
}
}
Spacer()
if run.output != nil || run.error != nil {
Button {
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
} label: {
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
if expanded {
if let err = run.error {
Text(err)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.red)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.red.opacity(0.06))
.cornerRadius(6)
.textSelection(.enabled)
} else if let out = run.output {
Text(out)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.primary)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.07))
.cornerRadius(6)
.lineLimit(20)
.textSelection(.enabled)
}
}
}
.padding(.vertical, 4)
}
@ViewBuilder
private var statusIcon: some View {
switch run.status {
case "running":
ProgressView().scaleEffect(0.6).frame(width: 14, height: 14)
case "completed":
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(.green)
case "failed":
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(.red)
case "stopped":
Image(systemName: "stop.circle.fill")
.font(.system(size: 14))
.foregroundStyle(.orange)
default:
Image(systemName: "circle")
.font(.system(size: 14))
.foregroundStyle(.secondary)
}
}
}
// MARK: - Agent Editor Sheet
struct JarvisAgentEditorSheet: View {
let agent: JarvisAgent?
let onSave: (JarvisAgentInput) async -> Void
@Environment(\.dismiss) var dismiss
@State private var name: String
@State private var description: String
@State private var model: String
@State private var prompt: String
@State private var schedule: String
@State private var enabled: Bool
@State private var maxToolCalls: String
@State private var isSaving = false
init(agent: JarvisAgent?, onSave: @escaping (JarvisAgentInput) async -> Void) {
self.agent = agent
self.onSave = onSave
_name = State(initialValue: agent?.name ?? "")
_description = State(initialValue: agent?.description ?? "")
_model = State(initialValue: agent?.model ?? "")
_prompt = State(initialValue: agent?.prompt ?? "")
_schedule = State(initialValue: agent?.schedule ?? "")
_enabled = State(initialValue: agent?.enabled ?? true)
_maxToolCalls = State(initialValue: agent.flatMap(\.maxToolCalls).map(String.init) ?? "")
}
var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && !model.trimmingCharacters(in: .whitespaces).isEmpty }
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text(agent == nil ? "New Agent" : "Edit Agent")
.font(.system(size: 16, weight: .semibold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title3).foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Group {
editorField("Name") {
TextField("Agent name", text: $name)
.textFieldStyle(.roundedBorder)
}
editorField("Description") {
TextField("Optional description", text: $description)
.textFieldStyle(.roundedBorder)
}
editorField("Model") {
TextField("e.g. anthropic/claude-sonnet-4-5", text: $model)
.textFieldStyle(.roundedBorder)
}
editorField("Schedule") {
TextField("Cron expression (e.g. 0 * * * *)", text: $schedule)
.textFieldStyle(.roundedBorder)
}
editorField("Max Tool Calls") {
TextField("Leave blank for default", text: $maxToolCalls)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 160)
}
editorField("Enabled") {
Toggle("", isOn: $enabled)
.toggleStyle(.switch)
.labelsHidden()
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Prompt")
.font(.system(size: 13, weight: .medium))
TextEditor(text: $prompt)
.font(.system(size: 13, design: .monospaced))
.frame(minHeight: 160)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.gray.opacity(0.3))
)
}
}
.padding(20)
}
Divider()
HStack(spacing: 10) {
Spacer()
Button("Cancel") { dismiss() }
.keyboardShortcut(.escape, modifiers: [])
Button {
Task {
isSaving = true
let input = JarvisAgentInput(
name: name.trimmingCharacters(in: .whitespaces),
prompt: prompt,
model: model.trimmingCharacters(in: .whitespaces),
description: description,
enabled: enabled,
schedule: schedule.isEmpty ? nil : schedule,
maxToolCalls: Int(maxToolCalls)
)
await onSave(input)
isSaving = false
dismiss()
}
} label: {
if isSaving {
ProgressView().scaleEffect(0.8).frame(width: 16, height: 16)
} else {
Text(agent == nil ? "Create" : "Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(!isValid || isSaving)
.keyboardShortcut(.return, modifiers: .command)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.frame(minWidth: 560, idealWidth: 620, minHeight: 560, idealHeight: 700)
}
private func editorField<Content: View>(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(label)
.font(.system(size: 13))
.frame(width: 100, alignment: .trailing)
content()
}
}
}
+80
View File
@@ -29,6 +29,7 @@ struct ModelInfoView: View {
let model: ModelInfo
@Environment(\.dismiss) var dismiss
@Bindable private var settings = SettingsService.shared
var body: some View {
VStack(spacing: 0) {
@@ -37,6 +38,15 @@ struct ModelInfoView: View {
Text("Model Info")
.font(.system(size: 18, weight: .bold))
Spacer()
let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) {
Image(systemName: isFav ? "star.fill" : "star")
.font(.system(size: 18))
.foregroundColor(isFav ? .yellow : .secondary)
}
.buttonStyle(.plain)
.help(isFav ? "Remove from favorites" : "Add to favorites")
.padding(.trailing, 8)
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
@@ -135,6 +145,32 @@ struct ModelInfoView: View {
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
}
// Categories (if any)
if !model.categories.isEmpty {
Divider()
sectionHeader("Categories")
FlowLayout(spacing: 8) {
ForEach(model.categories, id: \.rawValue) { cat in
HStack(spacing: 5) {
Image(systemName: cat.systemImage)
.font(.caption2)
Text(LocalizedStringKey(cat.rawValue))
.font(.caption)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(cat.color.opacity(0.12))
.foregroundColor(cat.color)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(cat.color.opacity(0.35), lineWidth: 1)
)
}
}
.padding(.leading, 4)
}
// Architecture (if available)
if let arch = model.architecture {
Divider()
@@ -228,6 +264,50 @@ struct ModelInfoView: View {
}
}
// MARK: - Flow Layout
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
let rows = rows(for: subviews, width: proposal.width ?? .infinity)
let height = rows.map { row in
row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
}.reduce(0, +) + CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
let rows = rows(for: subviews, width: bounds.width)
var y = bounds.minY
for row in rows {
var x = bounds.minX
let rowH = row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
for sub in row {
let size = sub.sizeThatFits(.unspecified)
sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
y += rowH + spacing
}
}
private func rows(for subviews: Subviews, width: CGFloat) -> [[LayoutSubview]] {
var rows: [[LayoutSubview]] = [[]]
var rowWidth: CGFloat = 0
for sub in subviews {
let w = sub.sizeThatFits(.unspecified).width
if rowWidth + w > width, !rows.last!.isEmpty {
rows.append([])
rowWidth = 0
}
rows[rows.count - 1].append(sub)
rowWidth += w + spacing
}
return rows
}
}
#Preview {
ModelInfoView(model: ModelInfo(
id: "anthropic/claude-sonnet-4",
+156 -5
View File
@@ -37,9 +37,17 @@ struct ModelSelectorView: View {
@State private var filterOnline = false
@State private var filterImageGen = false
@State private var filterThinking = false
@State private var filterFavorites = false
@State private var selectedCategory: ModelCategory? = nil
@State private var showCategoryPicker = false
@State private var keyboardIndex: Int = -1
@State private var sortOrder: ModelSortOrder = .default
@State private var selectedInfoModel: ModelInfo? = nil
@Bindable private var settings = SettingsService.shared
private var categoriesWithModels: Set<ModelCategory> {
Set(models.flatMap(\.categories))
}
private var filteredModels: [ModelInfo] {
let q = searchText.lowercased()
@@ -54,13 +62,21 @@ struct ModelSelectorView: View {
let matchesOnline = !filterOnline || model.capabilities.online
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
let matchesThinking = !filterThinking || model.capabilities.thinking
let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
let matchesCategory = selectedCategory == nil || model.categories.contains(selectedCategory!)
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites && matchesCategory
}
let favIds = settings.favoriteModelIds
switch sortOrder {
case .default:
return filtered
return filtered.sorted { a, b in
let aFav = favIds.contains(a.id)
let bFav = favIds.contains(b.id)
if aFav != bFav { return aFav }
return false
}
case .priceLowHigh:
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
case .priceHighLow:
@@ -91,6 +107,53 @@ struct ModelSelectorView: View {
Spacer()
// Category picker (only shown when at least one category has models)
if !categoriesWithModels.isEmpty {
Button(action: { showCategoryPicker.toggle() }) {
HStack(spacing: 4) {
if let cat = selectedCategory {
Circle().fill(cat.color).frame(width: 7, height: 7)
Text(LocalizedStringKey(cat.rawValue))
} else {
Image(systemName: "tag")
Text("Category")
}
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(selectedCategory != nil ? selectedCategory!.color.opacity(0.18) : Color.gray.opacity(0.1))
.foregroundColor(selectedCategory != nil ? selectedCategory!.color : .secondary)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(selectedCategory != nil ? selectedCategory!.color.opacity(0.4) : Color.clear, lineWidth: 1)
)
}
.buttonStyle(.plain)
.help("Filter by category")
.popover(isPresented: $showCategoryPicker, arrowEdge: .bottom) {
CategoryPickerPopover(
categoriesWithModels: categoriesWithModels,
selectedCategory: $selectedCategory,
onSelect: { keyboardIndex = -1 }
)
}
} // end if !categoriesWithModels.isEmpty
// Favorites filter star
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
Image(systemName: filterFavorites ? "star.fill" : "star")
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(filterFavorites ? Color.yellow.opacity(0.25) : Color.gray.opacity(0.1))
.foregroundColor(filterFavorites ? .yellow : .secondary)
.cornerRadius(6)
}
.buttonStyle(.plain)
.help("Show favorites only")
// Sort menu
Menu {
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
@@ -140,7 +203,9 @@ struct ModelSelectorView: View {
model: model,
isSelected: model.id == selectedModel?.id,
isKeyboardHighlighted: index == keyboardIndex,
isFavorite: settings.favoriteModelIds.contains(model.id),
onSelect: { onSelect(model) },
onFavorite: { settings.toggleFavoriteModel(model.id) },
onInfo: { selectedInfoModel = model }
)
.id(model.id)
@@ -156,7 +221,7 @@ struct ModelSelectorView: View {
}
}
}
.frame(minWidth: 600, minHeight: 500)
.frame(minWidth: 740, minHeight: 500)
.navigationTitle("Select Model")
#if os(macOS)
.onKeyPress(.downArrow) {
@@ -231,6 +296,7 @@ struct FilterToggle: View {
HStack(spacing: 4) {
Text(icon)
Text(label)
.lineLimit(1)
}
.font(.caption)
.padding(.horizontal, 10)
@@ -240,6 +306,68 @@ struct FilterToggle: View {
.cornerRadius(6)
}
.buttonStyle(.plain)
.fixedSize()
}
}
// MARK: - Category Picker Popover
struct CategoryPickerPopover: View {
let categoriesWithModels: Set<ModelCategory>
@Binding var selectedCategory: ModelCategory?
let onSelect: () -> Void
private let columns = [GridItem(.adaptive(minimum: 130), spacing: 8)]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Filter by Category")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
if selectedCategory != nil {
Button("Clear") {
selectedCategory = nil
onSelect()
}
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(.blue)
}
}
LazyVGrid(columns: columns, spacing: 8) {
ForEach(ModelCategory.allCases.filter { categoriesWithModels.contains($0) }, id: \.rawValue) { cat in
let isSelected = selectedCategory == cat
Button {
selectedCategory = isSelected ? nil : cat
onSelect()
} label: {
HStack(spacing: 6) {
Image(systemName: cat.systemImage)
.font(.caption)
.frame(width: 14)
Text(LocalizedStringKey(cat.rawValue))
.font(.caption)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(isSelected ? cat.color.opacity(0.18) : Color.gray.opacity(0.08))
.foregroundColor(isSelected ? cat.color : .primary)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(isSelected ? cat.color.opacity(0.45) : Color.clear, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(14)
.frame(minWidth: 360)
}
}
@@ -249,14 +377,25 @@ struct ModelRowView: View {
let model: ModelInfo
let isSelected: Bool
var isKeyboardHighlighted: Bool = false
var isFavorite: Bool = false
let onSelect: () -> Void
var onFavorite: (() -> Void)? = nil
let onInfo: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 8) {
// Selectable main content
VStack(alignment: .leading, spacing: 6) {
HStack {
HStack(spacing: 6) {
if let onFavorite {
Button(action: onFavorite) {
Image(systemName: isFavorite ? "star.fill" : "star")
.font(.system(size: 13))
.foregroundColor(isFavorite ? .yellow : .secondary)
}
.buttonStyle(.plain)
.help(isFavorite ? "Remove from favorites" : "Add to favorites")
}
Text(model.name)
.font(.headline)
.foregroundColor(isSelected ? .blue : .primary)
@@ -289,7 +428,7 @@ struct ModelRowView: View {
.contentShape(Rectangle())
.onTapGesture { onSelect() }
// Right side: capabilities + info button
// Right side: capabilities + category dots + info button
VStack(alignment: .trailing, spacing: 6) {
// Capability icons
HStack(spacing: 4) {
@@ -300,6 +439,18 @@ struct ModelRowView: View {
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
}
// Category dots
if !model.categories.isEmpty {
HStack(spacing: 3) {
ForEach(model.categories, id: \.rawValue) { cat in
Circle()
.fill(cat.color)
.frame(width: 7, height: 7)
.help(cat.rawValue)
}
}
}
// Info button
Button(action: onInfo) {
Image(systemName: "info.circle")
+336 -17
View File
@@ -25,6 +25,7 @@
import SwiftUI
import UniformTypeIdentifiers
import FoundationModels
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@@ -58,6 +59,23 @@ struct SettingsView: View {
@State private var syncTestResult: String?
@State private var isSyncing = false
// Anytype state
@State private var anytypeAPIKey = ""
@State private var anytypeURL = ""
@State private var showAnytypeKey = false
@State private var isTestingAnytype = false
@State private var anytypeTestResult: String?
// Jarvis state
@State private var jarvisURL = ""
@State private var jarvisAPIKey = ""
@State private var showJarvisKey = false
@State private var isTestingJarvis = false
@State private var jarvisTestResult: String?
// Default model picker state
@State private var showDefaultModelPicker = false
// Paperless-NGX state
@State private var paperlessURL = ""
@State private var paperlessToken = ""
@@ -136,14 +154,16 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
tabButton(2, icon: "paintbrush", label: "Appearance")
tabButton(3, icon: "slider.horizontal.3", label: "Advanced")
Divider().frame(height: 44).padding(.horizontal, 8)
Divider().frame(height: 44).padding(.horizontal, 4)
tabButton(6, icon: "command", label: "Shortcuts")
tabButton(7, icon: "brain", label: "Skills")
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
tabButton(5, icon: "envelope", label: "Email")
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
tabButton(11, icon: "server.rack", label: "Jarvis")
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
@@ -173,6 +193,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
paperlessTab
case 9:
backupTab
case 10:
anytypeTab
case 11:
jarvisTab
default:
generalTab
}
@@ -183,6 +207,20 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
.frame(minWidth: 860, idealWidth: 940, minHeight: 620, idealHeight: 760)
.sheet(isPresented: $showDefaultModelPicker) {
ModelSelectorView(
models: chatViewModel?.availableModels ?? [],
selectedModel: chatViewModel?.availableModels.first(where: { $0.id == settingsService.defaultModel }),
onSelect: { model in
let provider = chatViewModel.flatMap { vm in
vm.inferProviderPublic(from: model.id)
} ?? settingsService.defaultProvider
settingsService.defaultModel = model.id
settingsService.defaultProvider = provider
showDefaultModelPicker = false
}
)
}
.sheet(isPresented: $showEmailLog) {
EmailLogView()
}
@@ -269,6 +307,29 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// Apple Intelligence
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Apple Intelligence")
formSection {
row("Status") {
appleIntelligenceStatusBadge
}
rowDivider()
row("Model") {
Text("On-Device (4K context)")
.foregroundStyle(.secondary)
}
rowDivider()
row("") {
Button("Open Apple Intelligence Settings") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.aisettings") {
NSWorkspace.shared.open(url)
}
}
}
}
}
// Features
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Features")
@@ -364,13 +425,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Model Settings")
formSection {
row("Default Model ID") {
TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
get: { settingsService.defaultModel ?? "" },
set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 300)
row("Default Model") {
HStack(spacing: 8) {
let modelName: String = {
if let id = settingsService.defaultModel {
return chatViewModel?.availableModels.first(where: { $0.id == id })?.name ?? id
}
return "Not set"
}()
Text(modelName)
.foregroundStyle(settingsService.defaultModel == nil ? .secondary : .primary)
.frame(maxWidth: 240, alignment: .leading)
Button("Choose…") { showDefaultModelPicker = true }
.buttonStyle(.borderless)
if settingsService.defaultModel != nil {
Button("Clear") {
settingsService.defaultModel = nil
settingsService.defaultProvider = .openrouter
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
}
}
}
}
}
@@ -749,7 +825,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
formSection {
row("Icon Size") {
HStack(spacing: 8) {
Slider(value: $settingsService.toolbarIconSize, in: 16...32, step: 2)
Slider(value: $settingsService.toolbarIconSize, in: 16...40, step: 2)
.frame(maxWidth: 200)
Text("\(Int(settingsService.toolbarIconSize)) pt")
.font(.system(size: 13))
@@ -1803,6 +1879,13 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Toggle("", isOn: $settingsService.paperlessEnabled)
.toggleStyle(.switch)
}
VStack(alignment: .leading, spacing: 2) {
Text("⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
}
@@ -1902,6 +1985,218 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// MARK: - Anytype Tab
@ViewBuilder
private var anytypeTab: some View {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Anytype")
formSection {
row("Enable Anytype") {
Toggle("", isOn: $settingsService.anytypeMcpEnabled)
.toggleStyle(.switch)
}
}
}
if settingsService.anytypeMcpEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Connection")
formSection {
row("API URL") {
TextField("http://127.0.0.1:31009", text: $anytypeURL)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
.onSubmit { settingsService.anytypeMcpURL = anytypeURL }
.onChange(of: anytypeURL) { _, new in settingsService.anytypeMcpURL = new }
}
rowDivider()
row("API Key") {
HStack(spacing: 6) {
if showAnytypeKey {
TextField("", text: $anytypeAPIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { settingsService.anytypeMcpAPIKey = anytypeAPIKey.isEmpty ? nil : anytypeAPIKey }
.onChange(of: anytypeAPIKey) { _, new in
settingsService.anytypeMcpAPIKey = new.isEmpty ? nil : new
}
} else {
SecureField("", text: $anytypeAPIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { settingsService.anytypeMcpAPIKey = anytypeAPIKey.isEmpty ? nil : anytypeAPIKey }
.onChange(of: anytypeAPIKey) { _, new in
settingsService.anytypeMcpAPIKey = new.isEmpty ? nil : new
}
}
Button(showAnytypeKey ? "Hide" : "Show") {
showAnytypeKey.toggle()
}
.buttonStyle(.borderless)
.font(.system(size: 13))
}
}
rowDivider()
HStack(spacing: 12) {
Button(action: { Task { await testAnytypeConnection() } }) {
HStack {
if isTestingAnytype {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingAnytype || !settingsService.anytypeMcpConfigured)
if let result = anytypeTestResult {
Text(result)
.font(.system(size: 13))
.foregroundStyle(result.hasPrefix("") ? .green : .red)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("How to get your API key:")
.font(.system(size: 13, weight: .medium))
Text("1. Open Anytype → Settings → Integrations")
Text("2. Create a new API key")
Text("3. Paste it above")
}
.font(.system(size: 13))
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
}
}
.onAppear {
anytypeURL = settingsService.anytypeMcpURL
anytypeAPIKey = settingsService.anytypeMcpAPIKey ?? ""
}
}
// MARK: - Jarvis Tab
private var jarvisTab: some View {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Jarvis")
formSection {
row("Enable Jarvis") {
Toggle("", isOn: $settingsService.jarvisEnabled)
.toggleStyle(.switch)
}
}
}
if settingsService.jarvisEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Connection")
formSection {
row("Server URL") {
TextField("https://jarvis.example.com", text: $jarvisURL)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
.onSubmit { settingsService.jarvisURL = jarvisURL }
.onChange(of: jarvisURL) { _, new in settingsService.jarvisURL = new }
}
rowDivider()
row("API Key") {
HStack(spacing: 6) {
if showJarvisKey {
TextField("", text: $jarvisAPIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { settingsService.jarvisAPIKey = jarvisAPIKey.isEmpty ? nil : jarvisAPIKey }
.onChange(of: jarvisAPIKey) { _, new in
settingsService.jarvisAPIKey = new.isEmpty ? nil : new
}
} else {
SecureField("", text: $jarvisAPIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { settingsService.jarvisAPIKey = jarvisAPIKey.isEmpty ? nil : jarvisAPIKey }
.onChange(of: jarvisAPIKey) { _, new in
settingsService.jarvisAPIKey = new.isEmpty ? nil : new
}
}
Button(showJarvisKey ? "Hide" : "Show") {
showJarvisKey.toggle()
}
.buttonStyle(.borderless)
.font(.system(size: 13))
}
}
rowDivider()
HStack(spacing: 12) {
Button(action: { Task { await testJarvisConnection() } }) {
HStack {
if isTestingJarvis {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingJarvis || !settingsService.jarvisConfigured)
if let result = jarvisTestResult {
Text(result)
.font(.system(size: 13))
.foregroundStyle(result.hasPrefix("") ? .green : .red)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Generate an API key in your Jarvis settings and paste it above.")
}
.font(.system(size: 13))
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
}
}
.onAppear {
jarvisURL = settingsService.jarvisURL
jarvisAPIKey = settingsService.jarvisAPIKey ?? ""
}
}
private func testJarvisConnection() async {
isTestingJarvis = true
jarvisTestResult = nil
let ok = await JarvisService.shared.testConnection()
await MainActor.run {
jarvisTestResult = ok ? "✓ Connected" : "✗ Connection failed"
isTestingJarvis = false
}
}
private func testAnytypeConnection() async {
isTestingAnytype = true
anytypeTestResult = nil
let result = await AnytypeMCPService.shared.testConnection()
await MainActor.run {
switch result {
case .success(let msg):
anytypeTestResult = "\(msg)"
case .failure(let err):
anytypeTestResult = "\(err.localizedDescription)"
}
isTestingAnytype = false
}
}
// MARK: - Backup Tab
@ViewBuilder
@@ -2092,22 +2387,22 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
if beta {
Text("β")
.font(.system(size: 8, weight: .bold))
.font(.system(size: 9, weight: .heavy))
.foregroundStyle(.white)
.padding(.horizontal, 3)
.padding(.vertical, 1)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.orange)
.clipShape(Capsule())
.offset(x: 6, y: -2)
.offset(x: 8, y: -3)
}
}
Text(label)
.font(.system(size: 11))
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
}
.frame(minWidth: 68)
.frame(minWidth: 55)
.padding(.vertical, 6)
.padding(.horizontal, 6)
.padding(.horizontal, 4)
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
@@ -2126,6 +2421,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
case 7: return "Skills"
case 8: return "Paperless"
case 9: return "Backup"
case 10: return "Anytype"
case 11: return "Jarvis"
default: return "Settings"
}
}
@@ -2376,6 +2673,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: .now)
}
@ViewBuilder
private var appleIntelligenceStatusBadge: some View {
let availability = SystemLanguageModel.default.availability
switch availability {
case .available:
Label("Available", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
case .unavailable(.deviceNotEligible):
Label("Not supported on this Mac", systemImage: "xmark.circle.fill")
.foregroundStyle(.red)
case .unavailable(.appleIntelligenceNotEnabled):
Label("Not enabled — open Apple Intelligence Settings", systemImage: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
case .unavailable(.modelNotReady):
Label("Model downloading…", systemImage: "arrow.down.circle.fill")
.foregroundStyle(.orange)
default:
Label("Unavailable", systemImage: "questionmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
#Preview {
+36
View File
@@ -92,6 +92,8 @@ struct oAIApp: App {
CommandGroup(after: .newItem) {
Button("Open Chat…") { chatViewModel.showConversations = true }
.keyboardShortcut("o", modifiers: .command)
Button("Search Conversations") { chatViewModel.showConversations = true }
.keyboardShortcut("l", modifiers: .command)
}
CommandGroup(replacing: .saveItem) {
@@ -113,10 +115,44 @@ struct oAIApp: App {
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
}
// View menu
CommandMenu("View") {
Button("Select Model") { chatViewModel.showModelSelector = true }
.keyboardShortcut("m", modifiers: .command)
Button("Model Info") {
chatViewModel.modelInfoTarget = chatViewModel.selectedModel
}
.keyboardShortcut("i", modifiers: .command)
.disabled(chatViewModel.selectedModel == nil)
Divider()
Button("Command History") { chatViewModel.showHistory = true }
.keyboardShortcut("h", modifiers: .command)
Button("In-App Help") { chatViewModel.showHelp = true }
.keyboardShortcut("/", modifiers: .command)
Button("Credits") { chatViewModel.showCredits = true }
Divider()
Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") {
chatViewModel.onlineMode.toggle()
}
.keyboardShortcut("o", modifiers: [.command, .shift])
}
// Help menu
CommandGroup(replacing: .help) {
Button("oAI Help") { openHelp() }
.keyboardShortcut("?", modifiers: .command)
Divider()
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
UpdateCheckService.shared.checkForUpdatesManually()
}
.disabled(UpdateCheckService.shared.isCheckingManually)
}
}
#endif