Compare commits
9 Commits
40c5f25517
...
v2.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 52ab774785 | |||
| 5031dceff8 | |||
| 0cefef16e4 | |||
| 8cba92d768 | |||
| bfcdd0164c | |||
| 7119cd1d06 | |||
| e6f965ff19 | |||
| f2949cea3b | |||
| 56099c079c |
@@ -21,7 +21,7 @@ A powerful native macOS AI chat application with support for multiple providers,
|
||||
- **Reasoning / Thinking Tokens** - Stream live reasoning from thinking-capable models (DeepSeek R1, Claude 3.7+, o1/o3, Qwen); configurable effort level (High/Medium/Low/Minimal); collapsible block auto-expands while thinking and collapses when the answer arrives
|
||||
- **Online Mode** - DuckDuckGo and Google web search integration
|
||||
- **Session Statistics** - Track token usage, costs, and response times
|
||||
- **Command History** - Navigate previous commands with searchable modal (⌘H)
|
||||
- **Command History** - Navigate previous commands with searchable modal (⇧⌘H)
|
||||
|
||||
### 🧠 Enhanced Memory & Context System
|
||||
- **Smart Context Selection** - Automatically select relevant messages to reduce token usage by 50-80%
|
||||
@@ -193,7 +193,7 @@ Add your API keys in Settings (⌘,) → General tab:
|
||||
- `/load` or `/list` - List and load saved conversations (⌘L)
|
||||
- `/delete <name>` - Delete a saved conversation
|
||||
- `/export <md|json> [filename]` - Export conversation
|
||||
- `/history` - Open command history modal (⌘H)
|
||||
- `/history` - Open command history modal (⇧⌘H)
|
||||
|
||||
### Provider & Settings
|
||||
- `/provider [name]` - Switch or display current provider
|
||||
@@ -238,7 +238,7 @@ Can you review this code? @~/project/main.swift
|
||||
- `⌘,` - Open settings
|
||||
- `⌘N` - New conversation
|
||||
- `⌘L` - List saved conversations
|
||||
- `⌘H` - Command history
|
||||
- `⇧⌘H` - Command history
|
||||
- `Esc` - Cancel generation / Close dropdown
|
||||
- `↑/↓` - Navigate command dropdown (when typing `/`)
|
||||
- `Return` - Send message
|
||||
|
||||
@@ -47,6 +47,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
|
||||
let online: Bool // Web search
|
||||
var imageGeneration: Bool = false // Image output
|
||||
var thinking: Bool = false // Reasoning/thinking tokens
|
||||
var usesImagesAPI: Bool = false // OpenRouter dedicated /images endpoint
|
||||
}
|
||||
|
||||
struct Architecture: Codable, Hashable {
|
||||
|
||||
@@ -132,15 +132,19 @@ struct ChatResponse: Codable {
|
||||
let totalTokens: Int
|
||||
let cacheCreationInputTokens: Int?
|
||||
let cacheReadInputTokens: Int?
|
||||
/// Direct USD cost returned by the images API (bypasses token-based calculation).
|
||||
let rawCostUSD: Double?
|
||||
|
||||
init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil) {
|
||||
init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil, rawCostUSD: Double? = nil) {
|
||||
self.promptTokens = promptTokens
|
||||
self.completionTokens = completionTokens
|
||||
self.totalTokens = totalTokens
|
||||
self.cacheCreationInputTokens = cacheCreationInputTokens
|
||||
self.cacheReadInputTokens = cacheReadInputTokens
|
||||
self.rawCostUSD = rawCostUSD
|
||||
}
|
||||
|
||||
// rawCostUSD is set programmatically, never decoded from API responses
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case promptTokens = "prompt_tokens"
|
||||
case completionTokens = "completion_tokens"
|
||||
@@ -148,6 +152,16 @@ struct ChatResponse: Codable {
|
||||
case cacheCreationInputTokens = "cache_creation_input_tokens"
|
||||
case cacheReadInputTokens = "cache_read_input_tokens"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
promptTokens = try c.decode(Int.self, forKey: .promptTokens)
|
||||
completionTokens = try c.decode(Int.self, forKey: .completionTokens)
|
||||
totalTokens = try c.decode(Int.self, forKey: .totalTokens)
|
||||
cacheCreationInputTokens = try c.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens)
|
||||
cacheReadInputTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens)
|
||||
rawCostUSD = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Codable since ToolCallInfo/generatedImages are not from API directly
|
||||
|
||||
@@ -420,6 +420,67 @@ struct ToolResultMessage: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Images API — Model Discovery
|
||||
|
||||
struct OpenRouterImageModelsResponse: Codable {
|
||||
let data: [ImageModelData]
|
||||
|
||||
struct ImageModelData: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String?
|
||||
let architecture: Architecture?
|
||||
let supportsStreaming: Bool?
|
||||
|
||||
struct Architecture: Codable {
|
||||
let inputModalities: [String]?
|
||||
let outputModalities: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case inputModalities = "input_modalities"
|
||||
case outputModalities = "output_modalities"
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, description, architecture
|
||||
case supportsStreaming = "supports_streaming"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Images API — Generation Response
|
||||
|
||||
struct OpenRouterImageGenerationResponse: Codable {
|
||||
let created: Int?
|
||||
let data: [ImageData]
|
||||
let usage: Usage?
|
||||
|
||||
struct ImageData: Codable {
|
||||
let b64Json: String
|
||||
let mediaType: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case b64Json = "b64_json"
|
||||
case mediaType = "media_type"
|
||||
}
|
||||
}
|
||||
|
||||
struct Usage: Codable {
|
||||
let promptTokens: Int
|
||||
let completionTokens: Int
|
||||
let totalTokens: Int
|
||||
let cost: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case promptTokens = "prompt_tokens"
|
||||
case completionTokens = "completion_tokens"
|
||||
case totalTokens = "total_tokens"
|
||||
case cost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Response
|
||||
|
||||
struct OpenRouterErrorResponse: Codable {
|
||||
|
||||
@@ -53,30 +53,16 @@ class OpenRouterProvider: AIProvider {
|
||||
|
||||
func listModels() async throws -> [ModelInfo] {
|
||||
Log.api.info("Fetching model list from OpenRouter")
|
||||
let url = URL(string: "\(baseURL)/models")!
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
// Fetch chat models and image models in parallel
|
||||
async let chatData = fetchRaw(path: "/models")
|
||||
async let imageData = fetchRaw(path: "/images/models")
|
||||
let (chatRaw, imageRaw) = try await (chatData, imageData)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
Log.api.error("OpenRouter models: invalid response (not HTTP)")
|
||||
throw ProviderError.invalidResponse
|
||||
}
|
||||
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: chatRaw)
|
||||
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) chat models")
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
|
||||
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
|
||||
throw ProviderError.unknown(errorResponse.error.message)
|
||||
}
|
||||
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode)")
|
||||
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data)
|
||||
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) models")
|
||||
return modelsResponse.data.map { modelData in
|
||||
var models = modelsResponse.data.map { modelData in
|
||||
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
|
||||
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
|
||||
|
||||
@@ -129,8 +115,110 @@ class OpenRouterProvider: AIProvider {
|
||||
)
|
||||
return info
|
||||
}
|
||||
|
||||
// Merge dedicated image models (these don't appear in /models)
|
||||
if let imageModelsResponse = try? JSONDecoder().decode(OpenRouterImageModelsResponse.self, from: imageRaw) {
|
||||
Log.api.info("OpenRouter loaded \(imageModelsResponse.data.count) image models")
|
||||
let existingIds = Set(models.map { $0.id })
|
||||
let imageModels = imageModelsResponse.data.compactMap { m -> ModelInfo? in
|
||||
guard !existingIds.contains(m.id) else { return nil }
|
||||
let acceptsImageInput = m.architecture?.inputModalities?.contains("image") ?? false
|
||||
var info = ModelInfo(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
contextLength: 0,
|
||||
pricing: ModelInfo.Pricing(prompt: 0, completion: 0),
|
||||
capabilities: ModelInfo.ModelCapabilities(
|
||||
vision: acceptsImageInput,
|
||||
tools: false,
|
||||
online: false,
|
||||
imageGeneration: true,
|
||||
thinking: false,
|
||||
usesImagesAPI: true
|
||||
),
|
||||
topProvider: m.id.components(separatedBy: "/").first
|
||||
)
|
||||
info.categories = ModelCategory.infer(name: m.name, id: m.id, description: m.description)
|
||||
return info
|
||||
}
|
||||
models.append(contentsOf: imageModels)
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
|
||||
private func fetchRaw(path: String) async throws -> Data {
|
||||
let url = URL(string: "\(baseURL)\(path)")!
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else { throw ProviderError.invalidResponse }
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
if let err = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
|
||||
throw ProviderError.unknown(err.error.message)
|
||||
}
|
||||
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// MARK: - Images API
|
||||
|
||||
func generateImage(model: String, prompt: String) async throws -> ChatResponse {
|
||||
Log.api.info("OpenRouter images API: model=\(model)")
|
||||
let url = URL(string: "\(baseURL)/images")!
|
||||
var urlRequest = URLRequest(url: url)
|
||||
urlRequest.httpMethod = "POST"
|
||||
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
|
||||
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
|
||||
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: ["model": model, "prompt": prompt])
|
||||
|
||||
let (data, response) = try await session.data(for: urlRequest)
|
||||
guard let httpResponse = response as? HTTPURLResponse else { throw ProviderError.invalidResponse }
|
||||
|
||||
if httpResponse.statusCode != 200 {
|
||||
if let err = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
|
||||
Log.api.error("OpenRouter images HTTP \(httpResponse.statusCode): \(err.error.message)")
|
||||
throw ProviderError.unknown(err.error.message)
|
||||
}
|
||||
Log.api.error("OpenRouter images HTTP \(httpResponse.statusCode)")
|
||||
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
if let rawStr = String(data: data, encoding: .utf8) {
|
||||
Log.api.debug("Images API raw response (first 200 chars): \(rawStr.prefix(200))")
|
||||
}
|
||||
|
||||
let imageResponse = try JSONDecoder().decode(OpenRouterImageGenerationResponse.self, from: data)
|
||||
let images: [Data] = imageResponse.data.compactMap { item in
|
||||
Data(base64Encoded: item.b64Json)
|
||||
}
|
||||
|
||||
let usage: ChatResponse.Usage? = imageResponse.usage.map { u in
|
||||
ChatResponse.Usage(
|
||||
promptTokens: u.promptTokens,
|
||||
completionTokens: u.completionTokens,
|
||||
totalTokens: u.totalTokens,
|
||||
rawCostUSD: u.cost
|
||||
)
|
||||
}
|
||||
|
||||
return ChatResponse(
|
||||
id: UUID().uuidString,
|
||||
model: model,
|
||||
content: "",
|
||||
role: "assistant",
|
||||
finishReason: "stop",
|
||||
usage: usage,
|
||||
created: Date(),
|
||||
generatedImages: images.isEmpty ? nil : images
|
||||
)
|
||||
}
|
||||
|
||||
func getModel(_ id: String) async throws -> ModelInfo? {
|
||||
let models = try await listModels()
|
||||
return models.first { $0.id == id }
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
<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="#external-mcp">External MCP Servers</a></li>
|
||||
<li><a href="#personal-data">Personal Data Tools</a></li>
|
||||
<li><a href="#research-agents">Research Agents</a></li>
|
||||
<li><a href="#bash-execution">Bash Execution</a></li>
|
||||
<li><a href="#icloud-backup">iCloud Backup</a></li>
|
||||
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
|
||||
@@ -49,7 +52,7 @@
|
||||
<!-- Getting Started -->
|
||||
<section id="getting-started">
|
||||
<h2>Getting Started</h2>
|
||||
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama. The app is available in English, Norwegian Bokmål, Swedish, Danish, and German — it follows your macOS language preference automatically.</p>
|
||||
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama. The app is available in English, Norwegian Bokmål, Swedish, Danish, German, and French — it follows your macOS language preference automatically.</p>
|
||||
|
||||
<div class="steps">
|
||||
<h3>Quick Start</h3>
|
||||
@@ -115,6 +118,10 @@
|
||||
<li><strong>🧠 Thinking</strong> — models that support reasoning / thinking tokens</li>
|
||||
</ul>
|
||||
|
||||
<div class="note">
|
||||
<strong>Note:</strong> On OpenRouter, dedicated image-generation models (e.g. Sourceful, Seedream, Flux) are fetched from OpenRouter's separate images catalog and merged into the picker automatically — you don't need to configure anything extra to see them.
|
||||
</div>
|
||||
|
||||
<h3>Sorting</h3>
|
||||
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
|
||||
<ul>
|
||||
@@ -1370,7 +1377,6 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Keyboard Shortcuts -->
|
||||
<!-- Anytype Integration -->
|
||||
<section id="anytype">
|
||||
<h2>Anytype Integration</h2>
|
||||
@@ -1413,6 +1419,104 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- External MCP Servers -->
|
||||
<section id="external-mcp">
|
||||
<h2>External MCP Servers</h2>
|
||||
<p>Connect any external MCP server that speaks the stdio JSON-RPC protocol (for example <code>safaridriver --mcp</code>) and give the AI access to its tools — no custom integration code needed.</p>
|
||||
|
||||
<h3>Adding a Server</h3>
|
||||
<ol>
|
||||
<li>Press <kbd>⌘,</kbd> to open Settings</li>
|
||||
<li>Go to the <strong>MCP</strong> tab → <strong>External MCP Servers</strong> section</li>
|
||||
<li>Click <strong>Add Server…</strong></li>
|
||||
<li>Enter a <strong>Name</strong> (used to prefix its tools, e.g. "Safari" → <code>safari_navigate_to_url</code>), the <strong>Command</strong> to launch it, and any <strong>Arguments</strong></li>
|
||||
<li>Click <strong>Add</strong> — the server starts automatically and its tools are discovered</li>
|
||||
</ol>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Tip:</strong> Arguments containing spaces can be quoted, e.g. <code>--root "/Users/you/My Documents"</code>.
|
||||
</div>
|
||||
|
||||
<h3>Server Status</h3>
|
||||
<p>Each configured server shows a status dot and label:</p>
|
||||
<ul>
|
||||
<li><strong>🟢 Connected</strong> — running and its tools are available to the AI</li>
|
||||
<li><strong>🟠 Connecting…</strong> — starting up or performing the initial handshake</li>
|
||||
<li><strong>🔴 Error / Crashed</strong> — failed to start or exited unexpectedly</li>
|
||||
<li><strong>⚪ Not started</strong> — disabled via the toggle</li>
|
||||
</ul>
|
||||
<p>Toggle a server off/on or delete it entirely with the trash icon. Crashed servers automatically restart up to 3 times with increasing delay (5s, 15s, 30s) before giving up.</p>
|
||||
|
||||
<div class="note">
|
||||
<strong>Note:</strong> Tool names from every external server are prefixed with that server's slug (derived from its Name) so they never collide with oAI's built-in tools or each other.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Personal Data Tools -->
|
||||
<section id="personal-data">
|
||||
<h2>Personal Data Tools <span style="font-size: 0.75em; background: #f90; color: #fff; border-radius: 4px; padding: 1px 5px; vertical-align: middle;">Beta</span></h2>
|
||||
<p>Let the AI access your Calendar, Reminders, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses Apple's own frameworks (EventKit, CoreLocation, MapKit) with standard macOS permission prompts — nothing goes through a third-party service.</p>
|
||||
|
||||
<h3>Enabling a Service</h3>
|
||||
<ol>
|
||||
<li>Press <kbd>⌘,</kbd> to open Settings</li>
|
||||
<li>Go to the <strong>MCP</strong> tab → <strong>Personal Data</strong> section</li>
|
||||
<li>Toggle on the services you want: <strong>Calendar</strong>, <strong>Reminders</strong>, or <strong>Location & Maps</strong></li>
|
||||
<li>Click <strong>Request Access</strong> next to a service — macOS shows its standard permission prompt</li>
|
||||
</ol>
|
||||
|
||||
<h3>What the AI Can Do</h3>
|
||||
<ul>
|
||||
<li><strong>Calendar</strong> — list your calendars and upcoming events, create new events</li>
|
||||
<li><strong>Reminders</strong> — list reminder lists and items, create new reminders, mark reminders complete</li>
|
||||
<li><strong>Location & Maps</strong> — get your current location, search for places, geocode addresses, and get directions (all read-only)</li>
|
||||
</ul>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Write actions require approval:</strong> Creating a calendar event or reminder, or completing a reminder, shows an approval sheet first with a plain-language summary of what will happen. Choose <strong>Deny</strong>, <strong>Allow Once</strong>, or <strong>Allow for Session</strong>. Toggle this requirement off in Settings → MCP → Personal Data → Require Approval for Changes.
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Note:</strong> Contacts support exists internally but is currently hidden while a macOS permission bug affecting hardened-runtime apps is worked out on Apple's side.
|
||||
</div>
|
||||
|
||||
<h3>Example Prompts</h3>
|
||||
<ul>
|
||||
<li>"What's on my calendar tomorrow?"</li>
|
||||
<li>"Remind me to call the dentist on Friday at 2pm"</li>
|
||||
<li>"How far is the nearest coffee shop from here?"</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Research Agents -->
|
||||
<section id="research-agents">
|
||||
<h2>Research Agents</h2>
|
||||
<p>For tasks that involve searching or reading many files, the AI can spawn several read-only research sub-agents that work in parallel instead of doing everything itself, one step at a time.</p>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Cost warning:</strong> Each sub-agent runs its own full chain of model calls. A single request that spawns several agents can cost several times a normal reply. Leave this off unless you specifically want that tradeoff.
|
||||
</div>
|
||||
|
||||
<h3>Enabling Research Agents</h3>
|
||||
<ol>
|
||||
<li>Press <kbd>⌘,</kbd> to open Settings</li>
|
||||
<li>Go to the <strong>MCP</strong> tab → <strong>Research Agents</strong> section</li>
|
||||
<li>Toggle <strong>Enable Research Agents</strong> on</li>
|
||||
<li>Adjust <strong>Max Concurrent Agents</strong> (1–5, default 3) to control how many sub-agents can run at once</li>
|
||||
</ol>
|
||||
|
||||
<h3>What Sub-Agents Can Do</h3>
|
||||
<p>Sub-agents are intentionally limited to read-only investigation — they cannot write files, run shell commands, or spawn further sub-agents:</p>
|
||||
<ul>
|
||||
<li>Read file contents</li>
|
||||
<li>List directory contents</li>
|
||||
<li>Search for files</li>
|
||||
<li>Search the web</li>
|
||||
</ul>
|
||||
|
||||
<p class="note">Intended for genuinely independent research tasks (e.g. "compare these five files" or "look into three unrelated topics"), not everyday questions — the AI is instructed to reserve this for cases that actually benefit from parallelism.</p>
|
||||
</section>
|
||||
|
||||
<section id="keyboard-shortcuts">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<p>Work faster with these keyboard shortcuts.</p>
|
||||
@@ -1431,7 +1535,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<dt><kbd>⌘L</kbd></dt>
|
||||
<dd>Browse Conversations</dd>
|
||||
|
||||
<dt><kbd>⌘H</kbd></dt>
|
||||
<dt><kbd>⇧⌘H</kbd></dt>
|
||||
<dd>Command History</dd>
|
||||
|
||||
<dt><kbd>⌘M</kbd></dt>
|
||||
@@ -1490,6 +1594,9 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<li>Enable/disable write, delete, move, and bash execution permissions</li>
|
||||
<li>Configure gitignore respect</li>
|
||||
<li><strong>Bash Execution</strong> — enable AI shell access, set working directory, timeout, and approval behaviour (see <a href="#bash-execution">Bash Execution</a>)</li>
|
||||
<li><strong>Research Agents</strong> — let the AI spawn parallel read-only research sub-agents (see <a href="#research-agents">Research Agents</a>)</li>
|
||||
<li><strong>External MCP Servers</strong> — connect any stdio MCP server for additional tools (see <a href="#external-mcp">External MCP Servers</a>)</li>
|
||||
<li><strong>Personal Data</strong> — Calendar, Reminders, and Location & Maps access (see <a href="#personal-data">Personal Data Tools</a>)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Sync Tab</h3>
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - ExternalMCPClient
|
||||
|
||||
/// Manages one MCP stdio server process. All state is MainActor-isolated
|
||||
/// (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor project setting).
|
||||
/// Background I/O runs in Task.detached; state mutations hop back to MainActor.
|
||||
@MainActor
|
||||
final class ExternalMCPClient {
|
||||
let server: ExternalMCPServer
|
||||
weak var stateDelegate: (any ExternalMCPStateDelegate)?
|
||||
|
||||
private var process: Process?
|
||||
private var stdinHandle: FileHandle?
|
||||
private var readTask: Task<Void, Never>?
|
||||
private var stderrTask: Task<Void, Never>?
|
||||
|
||||
private var nextRequestId: Int = 1
|
||||
private var pendingCalls: [Int: CheckedContinuation<Data, Error>] = [:]
|
||||
private var lineBuffer = Data()
|
||||
|
||||
private(set) var state: MCPClientState = .idle
|
||||
private(set) var discoveredTools: [MCPToolDefinition] = []
|
||||
|
||||
init(server: ExternalMCPServer, stateDelegate: (any ExternalMCPStateDelegate)?) {
|
||||
self.server = server
|
||||
self.stateDelegate = stateDelegate
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func start() async throws {
|
||||
guard state == .idle || state == .stopped || state == .crashed else { return }
|
||||
state = .connecting
|
||||
stateDelegate?.clientDidChangeState(id: server.id, state: .connecting)
|
||||
|
||||
let proc = Process()
|
||||
if server.command.hasPrefix("/") {
|
||||
proc.executableURL = URL(fileURLWithPath: server.command)
|
||||
proc.arguments = server.args
|
||||
} else {
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
proc.arguments = [server.command] + server.args
|
||||
}
|
||||
proc.environment = ProcessInfo.processInfo.environment
|
||||
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
proc.standardInput = stdinPipe
|
||||
proc.standardOutput = stdoutPipe
|
||||
proc.standardError = stderrPipe
|
||||
|
||||
proc.terminationHandler = { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.handleProcessTerminated() }
|
||||
}
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
state = .error(error.localizedDescription)
|
||||
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
|
||||
throw MCPClientError.processLaunchFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
process = proc
|
||||
stdinHandle = stdinPipe.fileHandleForWriting
|
||||
startReadLoop(pipe: stdoutPipe)
|
||||
startStderrLoop(pipe: stderrPipe)
|
||||
|
||||
do {
|
||||
let _: MCPInitializeResult = try await timedRequest(seconds: 15, method: "initialize", params: [
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": [:] as [String: Any],
|
||||
"clientInfo": ["name": "oAI", "version": "1.0"] as [String: Any]
|
||||
])
|
||||
try sendNotification(method: "notifications/initialized")
|
||||
|
||||
let toolsResult: MCPToolsListResult = try await timedRequest(seconds: 15, method: "tools/list", params: nil)
|
||||
discoveredTools = toolsResult.tools
|
||||
} catch {
|
||||
state = .error(error.localizedDescription)
|
||||
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
|
||||
proc.terminate()
|
||||
throw error
|
||||
}
|
||||
|
||||
state = .ready
|
||||
stateDelegate?.clientDidBecomeReady(id: server.id, tools: discoveredTools, server: server)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
state = .stopped
|
||||
readTask?.cancel()
|
||||
stderrTask?.cancel()
|
||||
process?.terminate()
|
||||
process = nil
|
||||
stdinHandle = nil
|
||||
lineBuffer = Data()
|
||||
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
|
||||
pendingCalls.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Tool Execution
|
||||
|
||||
func callTool(originalName: String, argumentsJSON: String) async -> [String: Any] {
|
||||
guard state == .ready else {
|
||||
return ["error": "MCP server '\(server.name)' is not connected"]
|
||||
}
|
||||
guard let argData = argumentsJSON.data(using: .utf8),
|
||||
let argsDict = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
|
||||
return ["error": "Invalid arguments JSON for tool \(originalName)"]
|
||||
}
|
||||
do {
|
||||
let result: MCPToolCallResult = try await timedRequest(
|
||||
seconds: server.timeout,
|
||||
method: "tools/call",
|
||||
params: ["name": originalName, "arguments": argsDict]
|
||||
)
|
||||
return convertMCPResult(result)
|
||||
} catch MCPClientError.timeout {
|
||||
return ["error": "MCP server '\(server.name)' timed out after \(Int(server.timeout))s"]
|
||||
} catch {
|
||||
return ["error": "MCP call '\(originalName)' failed: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - I/O Loops (detached from MainActor)
|
||||
|
||||
private func startReadLoop(pipe: Pipe) {
|
||||
readTask = Task.detached { [weak self] in
|
||||
let handle = pipe.fileHandleForReading
|
||||
while true {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty { break }
|
||||
await self?.receiveData(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startStderrLoop(pipe: Pipe) {
|
||||
let name = server.name
|
||||
stderrTask = Task.detached {
|
||||
let handle = pipe.fileHandleForReading
|
||||
var buf = Data()
|
||||
while true {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty { break }
|
||||
buf.append(data)
|
||||
while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
let line = String(data: buf[buf.startIndex..<idx], encoding: .utf8) ?? ""
|
||||
buf = Data(buf[buf.index(after: idx)...])
|
||||
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
Log.extMcp.warning("[\(name)] \(line)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Processing (MainActor)
|
||||
|
||||
private func receiveData(_ data: Data) {
|
||||
lineBuffer.append(data)
|
||||
while let idx = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
let lineData = Data(lineBuffer[lineBuffer.startIndex..<idx])
|
||||
lineBuffer = Data(lineBuffer[lineBuffer.index(after: idx)...])
|
||||
processLine(lineData)
|
||||
}
|
||||
}
|
||||
|
||||
private func processLine(_ data: Data) {
|
||||
guard !data.isEmpty,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = json["id"] as? Int,
|
||||
let cont = pendingCalls.removeValue(forKey: id) else { return }
|
||||
|
||||
if let err = json["error"] as? [String: Any] {
|
||||
cont.resume(throwing: MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error"))
|
||||
} else if let result = json["result"],
|
||||
let resultData = try? JSONSerialization.data(withJSONObject: result) {
|
||||
cont.resume(returning: resultData)
|
||||
} else {
|
||||
cont.resume(throwing: MCPClientError.invalidResponse("Missing result field"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON-RPC
|
||||
|
||||
/// Send a JSON-RPC request with a per-call timeout. The timeout fires a cancellation
|
||||
/// directly into the pending-calls table rather than using a task group (which would
|
||||
/// pass the generic T through a @Sendable closure and trigger an isolated-conformance warning).
|
||||
private func timedRequest<T: Decodable>(seconds: Double, method: String, params: [String: Any]?) async throws -> T {
|
||||
let id = nextRequestId
|
||||
nextRequestId += 1
|
||||
var message: [String: Any] = ["jsonrpc": "2.0", "method": method, "id": id]
|
||||
if let params { message["params"] = params }
|
||||
try writeJSON(message)
|
||||
|
||||
// Schedule timeout: cancels the specific pending call by ID
|
||||
let timeoutId = id
|
||||
Task { [weak self, timeoutId] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout)
|
||||
}
|
||||
|
||||
// Await response data, then decode on MainActor
|
||||
let resultData: Data = try await withCheckedThrowingContinuation { cont in
|
||||
pendingCalls[id] = cont
|
||||
}
|
||||
return try JSONDecoder().decode(T.self, from: resultData)
|
||||
}
|
||||
|
||||
private func cancelPendingCall(id: Int, with error: Error) {
|
||||
pendingCalls.removeValue(forKey: id)?.resume(throwing: error)
|
||||
}
|
||||
|
||||
private func sendNotification(method: String) throws {
|
||||
try writeJSON(["jsonrpc": "2.0", "method": method])
|
||||
}
|
||||
|
||||
private func writeJSON(_ message: [String: Any]) throws {
|
||||
guard let handle = stdinHandle, process?.isRunning == true else {
|
||||
throw MCPClientError.writeFailed
|
||||
}
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: message),
|
||||
let line = String(data: data, encoding: .utf8) else {
|
||||
throw MCPClientError.writeFailed
|
||||
}
|
||||
do {
|
||||
try handle.write(contentsOf: Data((line + "\n").utf8))
|
||||
} catch {
|
||||
throw MCPClientError.writeFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Process termination
|
||||
|
||||
private func handleProcessTerminated() {
|
||||
guard state != .stopped else { return }
|
||||
state = .crashed
|
||||
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
|
||||
pendingCalls.removeAll()
|
||||
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
|
||||
}
|
||||
|
||||
// MARK: - Result conversion
|
||||
|
||||
private func convertMCPResult(_ result: MCPToolCallResult) -> [String: Any] {
|
||||
let isError = result.isError ?? false
|
||||
var parts: [String] = []
|
||||
for content in result.content {
|
||||
switch content.type {
|
||||
case "text":
|
||||
if let text = content.text { parts.append(text) }
|
||||
case "image":
|
||||
if let base64 = content.data, let imageData = Data(base64Encoded: base64) {
|
||||
parts.append("[Image saved to: \(writeTempImage(imageData, mimeType: content.mimeType))]")
|
||||
}
|
||||
case "resource":
|
||||
if let text = content.text { parts.append(text) }
|
||||
else if let uri = content.uri { parts.append("[Resource: \(uri)]") }
|
||||
default:
|
||||
if let text = content.text { parts.append(text) }
|
||||
}
|
||||
}
|
||||
let combined = parts.joined(separator: "\n")
|
||||
return isError ? ["error": combined.isEmpty ? "Tool returned an error" : combined] : ["output": combined]
|
||||
}
|
||||
|
||||
private func writeTempImage(_ data: Data, mimeType: String?) -> String {
|
||||
let ext = mimeType?.contains("png") == true ? "png" : "jpg"
|
||||
let path = "/tmp/oai_mcp_\(Int(Date().timeIntervalSince1970 * 1000)).\(ext)"
|
||||
try? data.write(to: URL(fileURLWithPath: path))
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - ExternalMCPManager
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ExternalMCPManager {
|
||||
nonisolated static let shared = ExternalMCPManager()
|
||||
|
||||
private(set) var clientStates: [UUID: MCPClientState] = [:]
|
||||
private(set) var cachedToolSchemas: [Tool] = []
|
||||
|
||||
// Keep server config alongside client so we can access slug without await
|
||||
private var clients: [UUID: ExternalMCPClient] = [:]
|
||||
private var serverConfigs: [UUID: ExternalMCPServer] = [:]
|
||||
private var restartTasks: [UUID: Task<Void, Never>] = [:]
|
||||
private var restartAttempts: [UUID: Int] = [:]
|
||||
|
||||
private nonisolated init() {}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func startAll() {
|
||||
for server in SettingsService.shared.externalMCPServers where server.isEnabled {
|
||||
startClient(for: server)
|
||||
}
|
||||
}
|
||||
|
||||
func stopAll() {
|
||||
for client in clients.values { client.stop() }
|
||||
clients.removeAll()
|
||||
serverConfigs.removeAll()
|
||||
clientStates.removeAll()
|
||||
cachedToolSchemas.removeAll()
|
||||
for task in restartTasks.values { task.cancel() }
|
||||
restartTasks.removeAll()
|
||||
restartAttempts.removeAll()
|
||||
}
|
||||
|
||||
func reconfigure(servers: [ExternalMCPServer]) {
|
||||
let activeIds = Set(servers.filter { $0.isEnabled }.map { $0.id })
|
||||
for id in clients.keys where !activeIds.contains(id) {
|
||||
clients[id]?.stop()
|
||||
clients.removeValue(forKey: id)
|
||||
serverConfigs.removeValue(forKey: id)
|
||||
clientStates.removeValue(forKey: id)
|
||||
restartTasks[id]?.cancel()
|
||||
restartTasks.removeValue(forKey: id)
|
||||
restartAttempts.removeValue(forKey: id)
|
||||
removeCachedSchemas(for: id)
|
||||
}
|
||||
for server in servers where server.isEnabled && clients[server.id] == nil {
|
||||
startClient(for: server)
|
||||
}
|
||||
}
|
||||
|
||||
private func startClient(for server: ExternalMCPServer) {
|
||||
// Stop any existing client for this ID before creating a new one
|
||||
clients[server.id]?.stop()
|
||||
let client = ExternalMCPClient(server: server, stateDelegate: self)
|
||||
clients[server.id] = client
|
||||
serverConfigs[server.id] = server
|
||||
clientStates[server.id] = .connecting
|
||||
Task {
|
||||
do {
|
||||
try await client.start()
|
||||
} catch MCPClientError.processLaunchFailed(let msg) {
|
||||
// Process never started — termination handler won't fire, so manually trigger crashed
|
||||
Log.extMcp.error("Failed to launch '\(server.name)': \(msg)")
|
||||
clientDidChangeState(id: server.id, state: .crashed)
|
||||
} catch {
|
||||
// Handshake/other failure — proc.terminate() was called in start(), termination
|
||||
// handler will fire and set .crashed, which drives the restart from one place only.
|
||||
Log.extMcp.warning("'\(server.name)' start failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRestart(for server: ExternalMCPServer, attempt: Int) {
|
||||
let delays: [Double] = [5, 15, 30]
|
||||
let delay = delays[min(attempt - 1, delays.count - 1)]
|
||||
Log.extMcp.warning("MCP server '\(server.name)' crashed — restarting in \(Int(delay))s (attempt \(attempt)/3)")
|
||||
|
||||
restartTasks[server.id]?.cancel()
|
||||
let id = server.id
|
||||
restartTasks[id] = Task { [weak self, id] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard !Task.isCancelled, let self,
|
||||
self.clients[id] != nil,
|
||||
SettingsService.shared.externalMCPServers.contains(where: { $0.id == id && $0.isEnabled })
|
||||
else { return }
|
||||
// startClient is the single place that creates and launches clients.
|
||||
// It handles processLaunchFailed by calling clientDidChangeState(.crashed),
|
||||
// and all other failures let the termination handler drive the .crashed callback.
|
||||
self.startClient(for: server)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool Schema Integration (synchronous)
|
||||
|
||||
func getToolSchemas() -> [Tool] { cachedToolSchemas }
|
||||
|
||||
func isExternalTool(_ name: String) -> Bool {
|
||||
cachedToolSchemas.contains { $0.function.name == name }
|
||||
}
|
||||
|
||||
// MARK: - Tool Execution
|
||||
|
||||
func executeTool(name: String, argumentsJSON: String) async -> [String: Any] {
|
||||
for (id, client) in clients {
|
||||
guard clientStates[id] == .ready,
|
||||
let server = serverConfigs[id] else { continue }
|
||||
let prefix = "\(server.slug)_"
|
||||
if name.hasPrefix(prefix) {
|
||||
let originalName = String(name.dropFirst(prefix.count))
|
||||
return await client.callTool(originalName: originalName, argumentsJSON: argumentsJSON)
|
||||
}
|
||||
}
|
||||
return ["error": "No external MCP server found for tool: \(name)"]
|
||||
}
|
||||
|
||||
// MARK: - Schema Cache
|
||||
|
||||
private func rebuildCache(for server: ExternalMCPServer, tools: [MCPToolDefinition]) {
|
||||
removeCachedSchemas(for: server.id, slug: server.slug)
|
||||
let prefixed = tools.compactMap { convertToolDefinition($0, server: server) }
|
||||
cachedToolSchemas.append(contentsOf: prefixed)
|
||||
Log.extMcp.info("[\(server.name)] cached \(prefixed.count) tools: \(prefixed.map { $0.function.name }.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
private func removeCachedSchemas(for id: UUID) {
|
||||
guard let server = serverConfigs[id] else { return }
|
||||
removeCachedSchemas(for: id, slug: server.slug)
|
||||
}
|
||||
|
||||
private func removeCachedSchemas(for id: UUID, slug: String) {
|
||||
cachedToolSchemas.removeAll { $0.function.name.hasPrefix("\(slug)_") }
|
||||
}
|
||||
|
||||
private func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
|
||||
Tool(
|
||||
type: "function",
|
||||
function: Tool.Function(
|
||||
name: "\(server.slug)_\(def.name)",
|
||||
description: "[\(server.name)] \(def.description ?? "")",
|
||||
parameters: convertInputSchema(def.inputSchema)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
|
||||
var properties: [String: Tool.Function.Parameters.Property] = [:]
|
||||
for (key, prop) in schema.properties ?? [:] {
|
||||
let normalized: String
|
||||
switch prop.type ?? "string" {
|
||||
case "integer": normalized = "number"
|
||||
case "string", "number", "boolean", "array", "object": normalized = prop.type!
|
||||
default: normalized = "string"
|
||||
}
|
||||
var items: Tool.Function.Parameters.Property.Items? = nil
|
||||
if normalized == "array", let t = prop.items?.type { items = .init(type: t) }
|
||||
properties[key] = Tool.Function.Parameters.Property(
|
||||
type: normalized,
|
||||
description: prop.description ?? "",
|
||||
enum: prop.enum,
|
||||
items: items
|
||||
)
|
||||
}
|
||||
return Tool.Function.Parameters(type: "object", properties: properties, required: schema.required)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExternalMCPStateDelegate
|
||||
|
||||
extension ExternalMCPManager: ExternalMCPStateDelegate {
|
||||
func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer) {
|
||||
clientStates[id] = .ready
|
||||
restartAttempts.removeValue(forKey: id)
|
||||
rebuildCache(for: server, tools: tools)
|
||||
}
|
||||
|
||||
func clientDidChangeState(id: UUID, state: MCPClientState) {
|
||||
clientStates[id] = state
|
||||
if case .crashed = state,
|
||||
let server = serverConfigs[id],
|
||||
SettingsService.shared.externalMCPServers.contains(where: { $0.id == id && $0.isEnabled }) {
|
||||
removeCachedSchemas(for: id, slug: server.slug)
|
||||
let attempt = (restartAttempts[id] ?? 0) + 1
|
||||
guard attempt <= 3 else {
|
||||
Log.extMcp.error("MCP server '\(server.name)' gave up after 3 restart attempts")
|
||||
clientStates[id] = .error("Maximum restart attempts reached")
|
||||
restartAttempts.removeValue(forKey: id)
|
||||
return
|
||||
}
|
||||
restartAttempts[id] = attempt
|
||||
scheduleRestart(for: server, attempt: attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Server Configuration
|
||||
|
||||
struct ExternalMCPServer: Codable, Identifiable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var command: String
|
||||
var args: [String]
|
||||
var isEnabled: Bool
|
||||
var timeout: TimeInterval
|
||||
var createdAt: Date
|
||||
|
||||
init(id: UUID = UUID(), name: String, command: String, args: [String] = [],
|
||||
isEnabled: Bool = true, timeout: TimeInterval = 30, createdAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.command = command
|
||||
self.args = args
|
||||
self.isEnabled = isEnabled
|
||||
self.timeout = timeout
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
var slug: String { Self.makeSlug(from: name) }
|
||||
|
||||
static func makeSlug(from name: String) -> String {
|
||||
let s = name
|
||||
.lowercased()
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "_")
|
||||
return s.isEmpty ? "ext" : s
|
||||
}
|
||||
|
||||
/// Splits a raw arguments string into tokens, respecting single/double-quoted
|
||||
/// segments so arguments containing spaces (e.g. `--root "/Users/x/My Documents"`)
|
||||
/// survive intact instead of being split on every space.
|
||||
static func parseArguments(_ input: String) -> [String] {
|
||||
var args: [String] = []
|
||||
var current = ""
|
||||
var inSingleQuotes = false
|
||||
var inDoubleQuotes = false
|
||||
|
||||
for char in input {
|
||||
if char == "'" && !inDoubleQuotes {
|
||||
inSingleQuotes.toggle()
|
||||
} else if char == "\"" && !inSingleQuotes {
|
||||
inDoubleQuotes.toggle()
|
||||
} else if char.isWhitespace && !inSingleQuotes && !inDoubleQuotes {
|
||||
if !current.isEmpty {
|
||||
args.append(current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
current.append(char)
|
||||
}
|
||||
}
|
||||
if !current.isEmpty { args.append(current) }
|
||||
return args
|
||||
}
|
||||
|
||||
static let reservedSlugs: Set<String> = [
|
||||
"anytype", "paperless", "calendar", "reminders",
|
||||
"contacts", "location", "maps", "bash", "web", "read", "write",
|
||||
"list", "search", "edit", "delete", "create", "move", "copy", "spawn"
|
||||
]
|
||||
|
||||
var isSlugReserved: Bool { Self.reservedSlugs.contains(slug) }
|
||||
}
|
||||
|
||||
// MARK: - Client State
|
||||
|
||||
enum MCPClientState: Equatable {
|
||||
case idle
|
||||
case connecting
|
||||
case ready
|
||||
case error(String)
|
||||
case crashed
|
||||
case stopped
|
||||
}
|
||||
|
||||
// MARK: - State Delegate (all callbacks on MainActor)
|
||||
|
||||
@MainActor
|
||||
protocol ExternalMCPStateDelegate: AnyObject {
|
||||
func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer)
|
||||
func clientDidChangeState(id: UUID, state: MCPClientState)
|
||||
}
|
||||
|
||||
// MARK: - Client Errors
|
||||
|
||||
enum MCPClientError: LocalizedError {
|
||||
case notConnected
|
||||
case invalidResponse(String)
|
||||
case timeout
|
||||
case processLaunchFailed(String)
|
||||
case handshakeFailed(String)
|
||||
case writeFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected: return "MCP server is not connected"
|
||||
case .invalidResponse(let s): return "Invalid MCP response: \(s)"
|
||||
case .timeout: return "MCP request timed out"
|
||||
case .processLaunchFailed(let s): return "Failed to launch MCP server: \(s)"
|
||||
case .handshakeFailed(let s): return "MCP handshake failed: \(s)"
|
||||
case .writeFailed: return "Failed to write to MCP server stdin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MCP Protocol Types
|
||||
|
||||
struct MCPInitializeResult: Decodable {
|
||||
let protocolVersion: String
|
||||
let capabilities: MCPCapabilities
|
||||
let serverInfo: MCPServerInfo?
|
||||
}
|
||||
|
||||
struct MCPCapabilities: Decodable {
|
||||
let tools: MCPToolsCapability?
|
||||
struct MCPToolsCapability: Decodable { let listChanged: Bool? }
|
||||
}
|
||||
|
||||
struct MCPServerInfo: Decodable {
|
||||
let name: String
|
||||
let version: String?
|
||||
}
|
||||
|
||||
struct MCPToolsListResult: Decodable {
|
||||
let tools: [MCPToolDefinition]
|
||||
let nextCursor: String?
|
||||
}
|
||||
|
||||
struct MCPToolDefinition: Decodable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let inputSchema: MCPInputSchema
|
||||
}
|
||||
|
||||
struct MCPInputSchema: Decodable {
|
||||
let type: String
|
||||
let properties: [String: MCPPropertySchema]?
|
||||
let required: [String]?
|
||||
}
|
||||
|
||||
struct MCPPropertySchema: Decodable {
|
||||
let type: String?
|
||||
let description: String?
|
||||
let `enum`: [String]?
|
||||
let items: MCPItemsSchema?
|
||||
struct MCPItemsSchema: Decodable { let type: String? }
|
||||
}
|
||||
|
||||
struct MCPToolCallResult: Decodable {
|
||||
let content: [MCPContent]
|
||||
let isError: Bool?
|
||||
}
|
||||
|
||||
struct MCPContent: Decodable {
|
||||
let type: String
|
||||
let text: String?
|
||||
let data: String?
|
||||
let mimeType: String?
|
||||
let uri: String?
|
||||
}
|
||||
@@ -98,6 +98,17 @@ class MCPService {
|
||||
|
||||
func isPathAllowed(_ path: String) -> Bool {
|
||||
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
// Always allow the system temp directory — external MCP servers and tools write
|
||||
// intermediate data there (e.g. Safari MCP page-content files, generated images).
|
||||
// Check both NSTemporaryDirectory() (per-user Darwin temp dir) and /tmp (what this
|
||||
// codebase's own temp files actually use, e.g. ChatViewModel's /tmp/oai_generated_*
|
||||
// and ExternalMCPClient's /tmp/oai_mcp_* — they resolve to different directories.
|
||||
let tmpCandidates = [
|
||||
(NSTemporaryDirectory() as NSString).standardizingPath,
|
||||
"/tmp",
|
||||
"/private/tmp"
|
||||
]
|
||||
if tmpCandidates.contains(where: { resolved.hasPrefix($0) }) { return true }
|
||||
return allowedFolders.contains { resolved.hasPrefix($0) }
|
||||
}
|
||||
|
||||
@@ -310,6 +321,9 @@ class MCPService {
|
||||
))
|
||||
}
|
||||
|
||||
// Add tools from external MCP servers (stdio JSON-RPC protocol)
|
||||
tools.append(contentsOf: ExternalMCPManager.shared.getToolSchemas())
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -472,6 +486,10 @@ class MCPService {
|
||||
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
|
||||
|
||||
default:
|
||||
// Route to external MCP servers (stdio JSON-RPC)
|
||||
if ExternalMCPManager.shared.isExternalTool(name) {
|
||||
return await ExternalMCPManager.shared.executeTool(name: name, argumentsJSON: arguments)
|
||||
}
|
||||
// Route anytype_* tools to AnytypeMCPService
|
||||
if name.hasPrefix("anytype_") {
|
||||
return await anytypeService.executeTool(name: name, arguments: arguments)
|
||||
|
||||
@@ -461,6 +461,48 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - External MCP Servers
|
||||
|
||||
var externalMCPServers: [ExternalMCPServer] {
|
||||
get {
|
||||
guard let json = cache["externalMCPServers"],
|
||||
let data = json.data(using: .utf8) else { return [] }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return (try? decoder.decode([ExternalMCPServer].self, from: data)) ?? []
|
||||
}
|
||||
set {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
if let data = try? encoder.encode(newValue),
|
||||
let json = String(data: data, encoding: .utf8) {
|
||||
cache["externalMCPServers"] = json
|
||||
DatabaseService.shared.setSetting(key: "externalMCPServers", value: json)
|
||||
}
|
||||
Task { @MainActor in ExternalMCPManager.shared.reconfigure(servers: newValue) }
|
||||
}
|
||||
}
|
||||
|
||||
func addExternalMCPServer(_ server: ExternalMCPServer) {
|
||||
externalMCPServers = externalMCPServers + [server]
|
||||
}
|
||||
|
||||
func updateExternalMCPServer(_ server: ExternalMCPServer) {
|
||||
externalMCPServers = externalMCPServers.map { $0.id == server.id ? server : $0 }
|
||||
}
|
||||
|
||||
func deleteExternalMCPServer(id: UUID) {
|
||||
externalMCPServers = externalMCPServers.filter { $0.id != id }
|
||||
}
|
||||
|
||||
func toggleExternalMCPServer(id: UUID) {
|
||||
externalMCPServers = externalMCPServers.map { s in
|
||||
s.id == id ? ExternalMCPServer(id: s.id, name: s.name, command: s.command,
|
||||
args: s.args, isEnabled: !s.isEnabled,
|
||||
timeout: s.timeout, createdAt: s.createdAt) : s
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Favorite Models
|
||||
|
||||
var favoriteModelIds: Set<String> {
|
||||
|
||||
@@ -153,4 +153,5 @@ enum Log {
|
||||
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")
|
||||
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
|
||||
}
|
||||
|
||||
@@ -360,7 +360,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
}
|
||||
|
||||
func startAutoContinue() {
|
||||
showSystemMessage("↩ Continuing…")
|
||||
silentContinuePrompt = "Please continue from where you left off."
|
||||
Task { @MainActor in
|
||||
generateAIResponse(to: "", attachments: nil)
|
||||
@@ -801,8 +800,16 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let bashActive = settings.bashEnabled
|
||||
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
|
||||
let researchAgentsActive = settings.agentsEnabled
|
||||
let externalMCPActive = !settings.externalMCPServers.filter { $0.isEnabled }.isEmpty
|
||||
// Dedicated images API path (OpenRouter /images endpoint — separate from chat completions)
|
||||
if selectedModel?.capabilities.usesImagesAPI == true,
|
||||
let orProvider = provider as? OpenRouterProvider {
|
||||
generateImageAPIResponse(orProvider: orProvider, modelId: modelId, prompt: prompt)
|
||||
return
|
||||
}
|
||||
|
||||
let modelSupportTools = selectedModel?.capabilities.tools ?? false
|
||||
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || externalMCPActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||
generateAIResponseWithTools(provider: provider, modelId: modelId)
|
||||
return
|
||||
}
|
||||
@@ -1263,6 +1270,55 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
// MARK: - AI Response with Tool Calls
|
||||
|
||||
// MARK: - Images API Generation
|
||||
|
||||
private func generateImageAPIResponse(orProvider: OpenRouterProvider, modelId: String, prompt: String) {
|
||||
isGenerating = true
|
||||
streamingTask?.cancel()
|
||||
streamingTask = Task {
|
||||
let startTime = Date()
|
||||
let assistantMessage = Message(
|
||||
role: .assistant,
|
||||
content: ThinkingVerbs.random(),
|
||||
tokens: nil,
|
||||
cost: nil,
|
||||
timestamp: Date(),
|
||||
attachments: nil,
|
||||
modelId: modelId,
|
||||
isStreaming: true
|
||||
)
|
||||
let messageId = assistantMessage.id
|
||||
messages.append(assistantMessage)
|
||||
|
||||
do {
|
||||
let response = try await orProvider.generateImage(model: modelId, prompt: prompt)
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
messages[index].content = response.content
|
||||
messages[index].isStreaming = false
|
||||
messages[index].generatedImages = response.generatedImages
|
||||
messages[index].responseTime = responseTime
|
||||
|
||||
if let usage = response.usage {
|
||||
messages[index].tokens = usage.completionTokens
|
||||
let cost = usage.rawCostUSD
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
_ = detectGoodbyePhrase(in: "")
|
||||
} catch {
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
|
||||
messages[index].isStreaming = false
|
||||
}
|
||||
Log.api.error("Images API error: \(error)")
|
||||
}
|
||||
isGenerating = false
|
||||
}
|
||||
}
|
||||
|
||||
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
|
||||
let mcp = MCPService.shared
|
||||
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
|
||||
@@ -1304,6 +1360,12 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.")
|
||||
}
|
||||
|
||||
let activeExternalServers = settings.externalMCPServers.filter { $0.isEnabled }
|
||||
if !activeExternalServers.isEmpty {
|
||||
let names = activeExternalServers.map { $0.name }.joined(separator: ", ")
|
||||
systemParts.append("You have access to external tools from MCP servers: \(names). Their tools are prefixed with the server slug (e.g. safari_navigate_to_url). Use them proactively when the user's request relates to what those servers provide.")
|
||||
}
|
||||
|
||||
var systemContent = systemParts.joined(separator: "\n\n")
|
||||
|
||||
// Append the complete system prompt (default + custom)
|
||||
@@ -1412,9 +1474,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
if finalContent.isEmpty {
|
||||
// Some models (observed with Qwen via OpenRouter) stop calling tools
|
||||
// after a long tool-call chain but return no summarizing text at all.
|
||||
// Surface a placeholder and nudge a follow-up turn instead of a silent blank bubble.
|
||||
// Silently nudge a follow-up turn instead of showing a placeholder bubble.
|
||||
finishedWithEmptyContent = true
|
||||
finalContent = "[No response from the model — retrying]"
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1503,9 +1564,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
// If this was the last iteration, note it
|
||||
if iteration == maxIterations - 1 {
|
||||
hitIterationLimit = true // We're exiting with pending tool calls
|
||||
finalContent = response.content.isEmpty
|
||||
? "[Tool loop reached maximum iterations]"
|
||||
: response.content
|
||||
finalContent = response.content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1514,41 +1573,57 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
wasCancelled = true
|
||||
}
|
||||
|
||||
// If we hit the iteration limit or the model returned no text at all, silently
|
||||
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
|
||||
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
|
||||
|
||||
// Display the final response as an assistant message
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
let assistantMessage = Message(
|
||||
role: .assistant,
|
||||
content: finalContent,
|
||||
tokens: totalUsage?.completionTokens,
|
||||
cost: nil,
|
||||
timestamp: Date(),
|
||||
attachments: nil,
|
||||
responseTime: responseTime,
|
||||
wasInterrupted: wasCancelled,
|
||||
modelId: modelId,
|
||||
generatedImages: finalImages.isEmpty ? nil : finalImages
|
||||
)
|
||||
messages.append(assistantMessage)
|
||||
|
||||
// Calculate cost
|
||||
if let usage = totalUsage, let model = selectedModel {
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
|
||||
messages[index].cost = cost
|
||||
if willAutoContinue && finalContent.isEmpty {
|
||||
// Nothing worth showing yet — still record usage/cost for this turn.
|
||||
if let usage = totalUsage, let model = selectedModel {
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
sessionStats.addMessage(
|
||||
inputTokens: usage.promptTokens,
|
||||
outputTokens: usage.completionTokens,
|
||||
cost: cost
|
||||
)
|
||||
}
|
||||
sessionStats.addMessage(
|
||||
inputTokens: usage.promptTokens,
|
||||
outputTokens: usage.completionTokens,
|
||||
cost: cost
|
||||
} else {
|
||||
let assistantMessage = Message(
|
||||
role: .assistant,
|
||||
content: finalContent,
|
||||
tokens: totalUsage?.completionTokens,
|
||||
cost: nil,
|
||||
timestamp: Date(),
|
||||
attachments: nil,
|
||||
responseTime: responseTime,
|
||||
wasInterrupted: wasCancelled,
|
||||
modelId: modelId,
|
||||
generatedImages: finalImages.isEmpty ? nil : finalImages
|
||||
)
|
||||
messages.append(assistantMessage)
|
||||
|
||||
// Calculate cost
|
||||
if let usage = totalUsage, let model = selectedModel {
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
|
||||
messages[index].cost = cost
|
||||
}
|
||||
sessionStats.addMessage(
|
||||
inputTokens: usage.promptTokens,
|
||||
outputTokens: usage.completionTokens,
|
||||
cost: cost
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
isGenerating = false
|
||||
streamingTask = nil
|
||||
|
||||
// If we hit the iteration limit, or the model returned no text at all, nudge a follow-up turn
|
||||
if (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled {
|
||||
if willAutoContinue {
|
||||
startAutoContinue()
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,15 @@ struct MarkdownContentView: View {
|
||||
.markdownBlockStyle(\.paragraph) { configuration in
|
||||
configuration.label
|
||||
.markdownMargin(top: 0, bottom: 8)
|
||||
// MarkdownUI builds mixed-style paragraphs (bold/italic runs alongside
|
||||
// plain text) as concatenated Text(+) segments, which on macOS report
|
||||
// their ideal (unwrapped, single-line) size instead of wrapping to the
|
||||
// width actually available — truncating with "…" mid-word. Forcing the
|
||||
// height to be recomputed for the given (flexible) width fixes it.
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
@@ -185,6 +185,7 @@ struct MessageRow: View {
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.messageBackground(for: message.role))
|
||||
|
||||
@@ -52,7 +52,7 @@ private let helpCategories: [CommandCategory] = [
|
||||
brief: "View command history",
|
||||
detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.",
|
||||
examples: ["/history"],
|
||||
shortcut: "⌘H"
|
||||
shortcut: "⇧⌘H"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/clear",
|
||||
|
||||
@@ -75,6 +75,14 @@ struct SettingsView: View {
|
||||
// Default model picker state
|
||||
@State private var showDefaultModelPicker = false
|
||||
|
||||
// External MCP Servers state
|
||||
@State private var showAddExternalMCPServer = false
|
||||
@State private var newMCPServerName = ""
|
||||
@State private var newMCPServerCommand = ""
|
||||
@State private var newMCPServerArgs = ""
|
||||
@State private var newMCPServerTimeout: Double = 30
|
||||
private var externalMCPManager = ExternalMCPManager.shared
|
||||
|
||||
// Personal Data state (Calendar/Reminders/Contacts/Location/Maps)
|
||||
@State private var calendarAccessState = EventKitService.shared.calendarAccessState
|
||||
@State private var remindersAccessState = EventKitService.shared.reminderAccessState
|
||||
@@ -817,6 +825,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: External MCP Servers
|
||||
Divider()
|
||||
externalMCPSection
|
||||
|
||||
// MARK: Personal Data
|
||||
// isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug).
|
||||
// isContactsHiddenPendingAppleFix hides just the Contacts row (still broken in beta 2
|
||||
@@ -917,6 +929,206 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - External MCP Servers Section
|
||||
|
||||
@ViewBuilder
|
||||
private var externalMCPSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "server.rack")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.purple)
|
||||
Text("External MCP Servers")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
}
|
||||
Text("Connect any stdio MCP server (e.g. safaridriver --mcp) to give the AI access to its tools. Tool names are prefixed with the server slug.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Configured Servers")
|
||||
formSection {
|
||||
if settingsService.externalMCPServers.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "server.rack")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No external servers configured")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Add a server below, e.g.: safaridriver --mcp")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 20)
|
||||
} else {
|
||||
ForEach(settingsService.externalMCPServers) { server in
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(mcpStatusColor(externalMCPManager.clientStates[server.id]))
|
||||
.frame(width: 8, height: 8)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.name)
|
||||
.font(.system(size: 14))
|
||||
Text(([server.command] + server.args).joined(separator: " "))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Text(mcpStatusLabel(externalMCPManager.clientStates[server.id]))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("", isOn: Binding(
|
||||
get: { server.isEnabled },
|
||||
set: { _ in settingsService.toggleExternalMCPServer(id: server.id) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Button {
|
||||
settingsService.deleteExternalMCPServer(id: server.id)
|
||||
} label: {
|
||||
Image(systemName: "trash.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
if server.id != settingsService.externalMCPServers.last?.id {
|
||||
rowDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
newMCPServerName = ""
|
||||
newMCPServerCommand = ""
|
||||
newMCPServerArgs = ""
|
||||
newMCPServerTimeout = 30
|
||||
showAddExternalMCPServer = true
|
||||
} label: {
|
||||
Label("Add Server…", systemImage: "plus")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(Color.purple)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.sheet(isPresented: $showAddExternalMCPServer) {
|
||||
addExternalMCPServerSheet
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var addExternalMCPServerSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Add External MCP Server")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
formSection {
|
||||
row("Name") {
|
||||
TextField("Safari", text: $newMCPServerName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 240)
|
||||
}
|
||||
rowDivider()
|
||||
row("Command") {
|
||||
TextField("safaridriver", text: $newMCPServerCommand)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.frame(width: 240)
|
||||
}
|
||||
rowDivider()
|
||||
row("Arguments") {
|
||||
TextField("--mcp", text: $newMCPServerArgs)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.frame(width: 240)
|
||||
.help("Space-separated arguments")
|
||||
}
|
||||
rowDivider()
|
||||
row("Timeout") {
|
||||
HStack(spacing: 8) {
|
||||
Stepper("", value: $newMCPServerTimeout, in: 5...120, step: 5)
|
||||
.labelsHidden()
|
||||
Text("\(Int(newMCPServerTimeout))s")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 32, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !newMCPServerName.isEmpty {
|
||||
let slug = ExternalMCPServer.makeSlug(from: newMCPServerName)
|
||||
HStack(spacing: 6) {
|
||||
Text("Tool prefix:")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(slug)_")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if ExternalMCPServer.reservedSlugs.contains(slug) {
|
||||
Text("'\(slug)' is a reserved prefix. Choose a different name.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") { showAddExternalMCPServer = false }
|
||||
Spacer()
|
||||
Button("Add") {
|
||||
let args = ExternalMCPServer.parseArguments(newMCPServerArgs)
|
||||
let server = ExternalMCPServer(
|
||||
name: newMCPServerName,
|
||||
command: newMCPServerCommand,
|
||||
args: args,
|
||||
timeout: newMCPServerTimeout
|
||||
)
|
||||
settingsService.addExternalMCPServer(server)
|
||||
showAddExternalMCPServer = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(newMCPServerName.isEmpty || newMCPServerCommand.isEmpty ||
|
||||
ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)))
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(minWidth: 460, minHeight: 320)
|
||||
}
|
||||
|
||||
private func mcpStatusColor(_ state: MCPClientState?) -> Color {
|
||||
switch state {
|
||||
case .ready: return .green
|
||||
case .connecting: return .orange
|
||||
case .error, .crashed: return .red
|
||||
default: return Color(nsColor: .tertiaryLabelColor)
|
||||
}
|
||||
}
|
||||
|
||||
private func mcpStatusLabel(_ state: MCPClientState?) -> LocalizedStringKey {
|
||||
switch state {
|
||||
case .ready: return "Connected"
|
||||
case .connecting: return "Connecting…"
|
||||
case .error: return "Error"
|
||||
case .crashed: return "Crashed"
|
||||
default: return "Not started"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Appearance Tab
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
+5
-2
@@ -37,6 +37,9 @@ struct oAIApp: App {
|
||||
// Start email handler on app launch
|
||||
EmailHandlerService.shared.start()
|
||||
|
||||
// Start external MCP servers
|
||||
Task { @MainActor in ExternalMCPManager.shared.startAll() }
|
||||
|
||||
// Sync Git changes on app launch (pull + import)
|
||||
Task {
|
||||
await GitSyncService.shared.syncOnStartup()
|
||||
@@ -56,7 +59,7 @@ struct oAIApp: App {
|
||||
}
|
||||
#if os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||
// Trigger auto-save on app quit
|
||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||
Task {
|
||||
await chatViewModel.onAppWillTerminate()
|
||||
}
|
||||
@@ -129,7 +132,7 @@ struct oAIApp: App {
|
||||
Divider()
|
||||
|
||||
Button("Command History") { chatViewModel.showHistory = true }
|
||||
.keyboardShortcut("h", modifiers: .command)
|
||||
.keyboardShortcut("h", modifiers: [.command, .shift])
|
||||
|
||||
Button("In-App Help") { chatViewModel.showHelp = true }
|
||||
.keyboardShortcut("/", modifiers: .command)
|
||||
|
||||
Reference in New Issue
Block a user