8 Commits

Author SHA1 Message Date
e9d0ad3c66 iCloud Backup, better chatview exp. bugfixes++ 2026-02-27 14:05:11 +01:00
3997f3feee updates 2026-02-25 08:24:05 +01:00
914d608d35 Updated help modal 2026-02-24 07:33:11 +01:00
11017ee7fa Updates 2026-02-23 13:45:58 +01:00
d386888359 Updates 2026-02-23 13:43:58 +01:00
079eccbc4e Several bugs fixed 2026-02-23 07:54:16 +01:00
56f79a690e Updated README.md 2026-02-21 13:15:29 +01:00
41185cc08b Version 2.3.2 2026-02-20 14:49:56 +01:00
20 changed files with 3108 additions and 1382 deletions

View File

@@ -8,7 +8,7 @@ A powerful native macOS AI chat application with support for multiple providers,
### 🤖 Multi-Provider Support
- **OpenAI** - GPT models with native API support
- **Anthropic** - Claude models with OAuth integration
- **Anthropic** - All Claude models
- **OpenRouter** - Access to 300+ AI models from multiple providers
- **Ollama** - Local model inference for privacy

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 551 KiB

View File

@@ -279,7 +279,7 @@
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.1;
MARKETING_VERSION = 2.3.4;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -323,7 +323,7 @@
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.1;
MARKETING_VERSION = 2.3.4;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;

View File

@@ -31,6 +31,14 @@ enum MessageRole: String, Codable {
case system
}
/// Detail for a single tool call within a system "🔧 Calling:" message.
/// Not persisted in-memory only.
struct ToolCallDetail {
let name: String
let input: String // raw JSON string of arguments
var result: String? // raw JSON string of result (nil while pending)
}
struct Message: Identifiable, Codable, Equatable {
let id: UUID
let role: MessageRole
@@ -52,6 +60,9 @@ struct Message: Identifiable, Codable, Equatable {
// Generated images from image-output models (base64-decoded PNG/JPEG data)
var generatedImages: [Data]? = nil
// Tool call details (not persisted in-memory only for expandable display)
var toolCalls: [ToolCallDetail]? = nil
init(
id: UUID = UUID(),
role: MessageRole,

View File

@@ -73,7 +73,11 @@ class AnthropicProvider: AIProvider {
// MARK: - Models
/// Local metadata used to enrich API results (pricing, context length) and as offline fallback.
/// Entries are matched by exact ID first; if no exact match is found, the enrichment step
/// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301")
/// still inherit the correct pricing tier.
private static let knownModels: [ModelInfo] = [
// Claude 4.x series
ModelInfo(
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
@@ -82,6 +86,31 @@ class AnthropicProvider: AIProvider {
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
description: "Best balance of speed and capability",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-haiku-4-6",
name: "Claude Haiku 4.6",
description: "Fastest and most affordable",
contextLength: 200_000,
pricing: .init(prompt: 0.80, completion: 4.0),
capabilities: .init(vision: true, tools: true, online: true)
),
// Claude 4.5 series
ModelInfo(
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
description: "Previous generation Opus",
contextLength: 200_000,
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
@@ -90,6 +119,14 @@ class AnthropicProvider: AIProvider {
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
@@ -98,6 +135,14 @@ class AnthropicProvider: AIProvider {
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-haiku-4-5",
name: "Claude Haiku 4.5",
description: "Fastest and most affordable",
contextLength: 200_000,
pricing: .init(prompt: 0.80, completion: 4.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
@@ -106,6 +151,7 @@ class AnthropicProvider: AIProvider {
pricing: .init(prompt: 0.80, completion: 4.0),
capabilities: .init(vision: true, tools: true, online: true)
),
// Claude 3.x series
ModelInfo(
id: "claude-3-7-sonnet-20250219",
name: "Claude 3.7 Sonnet",
@@ -124,6 +170,14 @@ class AnthropicProvider: AIProvider {
),
]
/// Pricing tiers used for fuzzy fallback matching on unknown model IDs.
/// Keyed by model name prefix (longest match wins).
private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [
("claude-opus", 15.0, 75.0),
("claude-sonnet", 3.0, 15.0),
("claude-haiku", 0.80, 4.0),
]
/// Fetch live model list from GET /v1/models, enriched with local pricing/context metadata.
/// Falls back to knownModels if the request fails (no key, offline, etc.).
func listModels() async throws -> [ModelInfo] {
@@ -158,14 +212,20 @@ class AnthropicProvider: AIProvider {
guard let id = item["id"] as? String,
id.hasPrefix("claude-") else { return nil }
let displayName = item["display_name"] as? String ?? id
// Exact match first
if let known = enrichment[id] { return known }
// Unknown new model use display name and sensible defaults
// Fuzzy fallback: find the longest prefix that matches
let fallback = Self.pricingFallback
.filter { id.hasPrefix($0.prefix) }
.max(by: { $0.prefix.count < $1.prefix.count })
let pricing = fallback.map { ModelInfo.Pricing(prompt: $0.prompt, completion: $0.completion) }
?? ModelInfo.Pricing(prompt: 0, completion: 0)
return ModelInfo(
id: id,
name: displayName,
description: item["description"] as? String ?? "",
contextLength: 200_000,
pricing: .init(prompt: 0, completion: 0),
pricing: pricing,
capabilities: .init(vision: true, tools: true, online: false)
)
}
@@ -258,7 +318,41 @@ class AnthropicProvider: AIProvider {
conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""])
}
} else {
conversationMessages.append(["role": role, "content": msg["content"] as? String ?? ""])
// Content may be a String (plain text) or an Array (image/multipart blocks)
if let contentArray = msg["content"] as? [[String: Any]] {
// Convert image_url blocks (OpenAI format) to Anthropic image blocks
let anthropicBlocks: [[String: Any]] = contentArray.compactMap { block in
let type = block["type"] as? String ?? ""
if type == "text" {
return block
} else if type == "image_url",
let imageUrl = block["image_url"] as? [String: Any],
let url = imageUrl["url"] as? String,
url.hasPrefix("data:") {
// data:<mediaType>;base64,<data>
let withoutData = url.dropFirst(5) // strip "data:"
guard let semicolon = withoutData.firstIndex(of: ";"),
let comma = withoutData.firstIndex(of: ",") else { return nil }
let mediaType = String(withoutData[withoutData.startIndex..<semicolon])
let base64Data = String(withoutData[withoutData.index(after: comma)...])
return [
"type": "image",
"source": [
"type": "base64",
"media_type": mediaType,
"data": base64Data
]
]
}
return nil
}
if !anthropicBlocks.isEmpty {
conversationMessages.append(["role": role, "content": anthropicBlocks])
}
} else {
let content = (msg["content"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
conversationMessages.append(["role": role, "content": content.isEmpty ? "[Image]" : content])
}
}
}
@@ -458,7 +552,9 @@ class AnthropicProvider: AIProvider {
if hasAttachments, let attachments = msg.attachments {
var contentBlocks: [[String: Any]] = []
contentBlocks.append(["type": "text", "text": msg.content])
if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
contentBlocks.append(["type": "text", "text": msg.content])
}
for attachment in attachments {
guard let data = attachment.data else { continue }
@@ -481,7 +577,8 @@ class AnthropicProvider: AIProvider {
}
apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks])
} else {
apiMessages.append(["role": msg.role.rawValue, "content": msg.content])
let content = msg.content.trimmingCharacters(in: .whitespacesAndNewlines)
apiMessages.append(["role": msg.role.rawValue, "content": content.isEmpty ? "[Image]" : content])
}
}

View File

@@ -0,0 +1,205 @@
//
// BackupService.swift
// oAI
//
// iCloud Drive backup of non-encrypted settings (Option C, v1)
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import Foundation
import os
// MARK: - BackupManifest
struct BackupManifest: Codable {
let version: Int
let createdAt: String
let appVersion: String
let credentialsIncluded: Bool
let settings: [String: String]
let credentials: [String: String]?
}
// MARK: - BackupService
@Observable
final class BackupService {
static let shared = BackupService()
private let log = Logger(subsystem: "oAI", category: "backup")
/// Whether iCloud Drive is available on this machine
var iCloudAvailable: Bool = false
/// Date of the last backup file on disk (from file attributes)
var lastBackupDate: Date?
/// URL of the last backup file
var lastBackupURL: URL?
// Keys excluded from backup encrypted_ prefix + internal migration flags
private static let excludedKeys: Set<String> = [
"encrypted_openrouterAPIKey",
"encrypted_anthropicAPIKey",
"encrypted_openaiAPIKey",
"encrypted_googleAPIKey",
"encrypted_googleSearchEngineID",
"encrypted_anytypeMcpAPIKey",
"encrypted_paperlessAPIToken",
"encrypted_syncUsername",
"encrypted_syncPassword",
"encrypted_syncAccessToken",
"encrypted_emailUsername",
"encrypted_emailPassword",
"_migrated",
"_keychain_migrated",
]
private init() {
checkForExistingBackup()
}
// MARK: - iCloud Path Resolution
private func resolveBackupDirectory() -> URL {
let home = FileManager.default.homeDirectoryForCurrentUser
let icloudRoot = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs")
if FileManager.default.fileExists(atPath: icloudRoot.path) {
let icloudOAI = icloudRoot.appendingPathComponent("oAI")
try? FileManager.default.createDirectory(at: icloudOAI, withIntermediateDirectories: true)
return icloudOAI
}
// Fallback: Downloads
return FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
}
func checkForExistingBackup() {
let home = FileManager.default.homeDirectoryForCurrentUser
let icloudRoot = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs")
iCloudAvailable = FileManager.default.fileExists(atPath: icloudRoot.path)
let dir = resolveBackupDirectory()
let fileURL = dir.appendingPathComponent("oai_backup.json")
if FileManager.default.fileExists(atPath: fileURL.path),
let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let modified = attrs[.modificationDate] as? Date {
lastBackupDate = modified
lastBackupURL = fileURL
}
}
// MARK: - Export
/// Export all non-encrypted settings to iCloud Drive (or Downloads).
/// Returns the URL where the file was written.
@discardableResult
func exportSettings() async throws -> URL {
// Load raw settings from DB
guard let allSettings = try? DatabaseService.shared.loadAllSettings() else {
throw BackupError.databaseReadFailed
}
// Filter out excluded keys
let filtered = allSettings.filter { !Self.excludedKeys.contains($0.key) }
// Build manifest
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
let manifest = BackupManifest(
version: 1,
createdAt: formatter.string(from: Date()),
appVersion: appVersion(),
credentialsIncluded: false,
settings: filtered,
credentials: nil
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(manifest)
let dir = resolveBackupDirectory()
let fileURL = dir.appendingPathComponent("oai_backup.json")
try data.write(to: fileURL, options: .atomic)
log.info("Backup written to \(fileURL.path, privacy: .public) (\(filtered.count) settings)")
await MainActor.run {
self.lastBackupDate = Date()
self.lastBackupURL = fileURL
}
return fileURL
}
// MARK: - Import
/// Restore settings from a backup JSON file.
func importSettings(from url: URL) async throws {
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
let manifest: BackupManifest
do {
manifest = try decoder.decode(BackupManifest.self, from: data)
} catch {
throw BackupError.invalidFormat(error.localizedDescription)
}
guard manifest.version == 1 else {
throw BackupError.unsupportedVersion(manifest.version)
}
// Write each setting to the database
for (key, value) in manifest.settings {
DatabaseService.shared.setSetting(key: key, value: value)
}
// Refresh in-memory cache
SettingsService.shared.reloadFromDatabase()
log.info("Restored \(manifest.settings.count) settings from backup (v\(manifest.version))")
}
// MARK: - Helpers
private func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}
}
// MARK: - BackupError
enum BackupError: LocalizedError {
case databaseReadFailed
case invalidFormat(String)
case unsupportedVersion(Int)
var errorDescription: String? {
switch self {
case .databaseReadFailed:
return "Could not read settings from the database."
case .invalidFormat(let detail):
return "The backup file is not valid: \(detail)"
case .unsupportedVersion(let v):
return "Backup version \(v) is not supported by this version of oAI."
}
}
}

View File

@@ -273,7 +273,7 @@ final class EmailHandlerService {
---
Please provide a complete, well-formatted response to this email. Your response will be sent as an HTML email.
Please provide a complete, well-formatted response to this email. Write the reply directly — do not wrap it in code fences or markdown blocks.
"""
return prompt
@@ -299,7 +299,8 @@ final class EmailHandlerService {
- Be professional and courteous
- Keep responses concise and relevant
- Use proper email etiquette
- Format your response using Markdown (it will be converted to HTML)
- Format your response using Markdown (bold, lists, headings are welcome)
- Do NOT wrap your response in code fences or ```html blocks — write the email body directly
- If you need information from files, you have read-only access via MCP tools
- Never claim to write, modify, or delete files (read-only access)
- Sign emails appropriately
@@ -412,27 +413,46 @@ final class EmailHandlerService {
}
private func markdownToHTML(_ markdown: String) -> String {
// Basic markdown to HTML conversion
// TODO: Use proper markdown parser for production
var html = markdown
var text = markdown.trimmingCharacters(in: .whitespacesAndNewlines)
// Paragraphs
html = html.replacingOccurrences(of: "\n\n", with: "</p><p>")
html = "<p>\(html)</p>"
// Strip outer code fence wrapping the entire response
// Models sometimes wrap their reply in ```html ... ``` or ``` ... ```
let lines = text.components(separatedBy: "\n")
if lines.count >= 2 {
let first = lines[0].trimmingCharacters(in: .whitespaces)
let last = lines[lines.count - 1].trimmingCharacters(in: .whitespaces)
if (first == "```" || first.hasPrefix("```")) && last == "```" {
text = lines.dropFirst().dropLast().joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
// Strip any remaining fenced code blocks (preserve content, remove fences)
text = text.replacingOccurrences(
of: #"```[a-z]*\n([\s\S]*?)```"#,
with: "$1",
options: .regularExpression
)
// Bold
html = html.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "<strong>$1</strong>", options: .regularExpression)
text = text.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "<strong>$1</strong>", options: .regularExpression)
// Italic
html = html.replacingOccurrences(of: #"\*(.+?)\*"#, with: "<em>$1</em>", options: .regularExpression)
// Italic (avoid matching bold's leftover *)
text = text.replacingOccurrences(of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#, with: "<em>$1</em>", options: .regularExpression)
// Inline code
html = html.replacingOccurrences(of: #"`(.+?)`"#, with: "<code>$1</code>", options: .regularExpression)
text = text.replacingOccurrences(of: #"`([^`\n]+)`"#, with: "<code>$1</code>", options: .regularExpression)
// Line breaks
html = html.replacingOccurrences(of: "\n", with: "<br>")
// Split into paragraphs on double newlines, wrap each in <p>
let paragraphs = text.components(separatedBy: "\n\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { para in
let withBreaks = para.replacingOccurrences(of: "\n", with: "<br>")
return "<p>\(withBreaks)</p>"
}
return html
return paragraphs.joined(separator: "\n")
}
// MARK: - Error Handling

View File

@@ -110,10 +110,23 @@ class MCPService {
var respectGitignore: Bool { settings.mcpRespectGitignore }
private let anytypeService = AnytypeMCPService.shared
private let paperlessService = PaperlessService.shared
// MARK: - Bash Approval State
struct PendingBashCommand: Identifiable {
let id = UUID()
let command: String
let workingDirectory: String
}
private(set) var pendingBashCommand: PendingBashCommand? = nil
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
private(set) var bashSessionApproved: Bool = false
// MARK: - Tool Schema Generation
func getToolSchemas() -> [Tool] {
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
var tools: [Tool] = [
makeTool(
name: "read_file",
@@ -214,6 +227,39 @@ class MCPService {
tools.append(contentsOf: anytypeService.getToolSchemas())
}
// Add Paperless-NGX tools if enabled and configured
if settings.paperlessEnabled && settings.paperlessConfigured {
tools.append(contentsOf: paperlessService.getToolSchemas())
}
// Add bash_execute tool when bash is enabled
if settings.bashEnabled {
let workDir = settings.bashWorkingDirectory
let timeout = settings.bashTimeout
let approvalNote = settings.bashRequireApproval ? " User approval required before execution." : ""
tools.append(makeTool(
name: "bash_execute",
description: "Execute a shell command via /bin/zsh and return stdout/stderr. Working directory: \(workDir). Timeout: \(timeout)s.\(approvalNote)",
properties: [
"command": prop("string", "The shell command to execute")
],
required: ["command"]
))
}
// Add web_search tool when online mode is active
// (OpenRouter handles search natively via :online model suffix, so excluded here)
if onlineMode {
tools.append(makeTool(
name: "web_search",
description: "Search the web for current information using DuckDuckGo. Use this when you need up-to-date information, news, or facts not in your training data. Formulate a concise, focused search query.",
properties: [
"query": prop("string", "The search query to look up")
],
required: ["query"]
))
}
return tools
}
@@ -327,11 +373,41 @@ class MCPService {
}
return copyFile(source: source, destination: destination)
case "bash_execute":
guard settings.bashEnabled else {
return ["error": "Bash execution is disabled. Enable it in Settings > MCP."]
}
guard let command = args["command"] as? String, !command.isEmpty else {
return ["error": "Missing required parameter: command"]
}
let workDir = settings.bashWorkingDirectory
if settings.bashRequireApproval {
return await executeBashWithApproval(command: command, workingDirectory: workDir)
} else {
return await runBashCommand(command, workingDirectory: workDir)
}
case "web_search":
let query = args["query"] as? String ?? ""
guard !query.isEmpty else {
return ["error": "Missing required parameter: query"]
}
let results = await WebSearchService.shared.search(query: query)
if results.isEmpty {
return ["results": [], "message": "No results found for: \(query)"]
}
let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] }
return ["results": mapped]
default:
// Route anytype_* tools to AnytypeMCPService
if name.hasPrefix("anytype_") {
return await anytypeService.executeTool(name: name, arguments: arguments)
}
// Route paperless_* tools to PaperlessService
if name.hasPrefix("paperless_") {
return await paperlessService.executeTool(name: name, arguments: arguments)
}
return ["error": "Unknown tool: \(name)"]
}
}
@@ -671,6 +747,113 @@ class MCPService {
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
}
// MARK: - Bash Execution
private func executeBashWithApproval(command: String, workingDirectory: String) async -> [String: Any] {
// If the user already approved all commands for this session, skip the UI
if bashSessionApproved {
return await runBashCommand(command, workingDirectory: workingDirectory)
}
return await withCheckedContinuation { continuation in
DispatchQueue.main.async {
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
self.pendingBashContinuation = continuation
}
}
}
func approvePendingBashCommand(forSession: Bool = false) {
guard let pending = pendingBashCommand, let cont = pendingBashContinuation else { return }
pendingBashCommand = nil
pendingBashContinuation = nil
if forSession {
bashSessionApproved = true
}
Task.detached(priority: .userInitiated) {
let result = await self.runBashCommand(pending.command, workingDirectory: pending.workingDirectory)
cont.resume(returning: result)
}
}
func denyPendingBashCommand() {
guard pendingBashCommand != nil else { return }
pendingBashCommand = nil
pendingBashContinuation?.resume(returning: ["error": "User denied command execution"])
pendingBashContinuation = nil
}
func resetBashSessionApproval() {
bashSessionApproved = false
}
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
let timeoutSeconds = settings.bashTimeout
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
Log.mcp.info("bash_execute: \(command)")
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-c", command]
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: workDir, isDirectory: &isDir), isDir.boolValue {
process.currentDirectoryURL = URL(fileURLWithPath: workDir)
}
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
var timedOut = false
let timeoutItem = DispatchWorkItem {
if process.isRunning {
timedOut = true
process.terminate()
}
}
DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(timeoutSeconds), execute: timeoutItem)
do {
try process.run()
process.waitUntilExit()
} catch {
timeoutItem.cancel()
Log.mcp.error("bash_execute failed to start: \(error.localizedDescription)")
continuation.resume(returning: ["error": "Failed to run command: \(error.localizedDescription)"])
return
}
timeoutItem.cancel()
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let exitCode = Int(process.terminationStatus)
Log.mcp.info("bash_execute exit=\(exitCode) stdout=\(stdout.count)b stderr=\(stderr.count)b timedOut=\(timedOut)")
var result: [String: Any] = ["exit_code": exitCode]
if !stdout.isEmpty {
let maxOut = 20_000
result["stdout"] = stdout.count > maxOut
? String(stdout.prefix(maxOut)) + "\n... (output truncated)"
: stdout
}
if !stderr.isEmpty {
result["stderr"] = String(stderr.prefix(5_000))
}
if timedOut {
result["timed_out"] = true
result["note"] = "Command terminated after \(timeoutSeconds)s timeout"
}
continuation.resume(returning: result)
}
}
}
// MARK: - Gitignore Support
/// Reload gitignore rules for all allowed folders

View File

@@ -0,0 +1,496 @@
//
// PaperlessService.swift
// oAI
//
// Paperless-NGX integration: search, read, and upload documents via REST API
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import Foundation
import os
@Observable
class PaperlessService {
static let shared = PaperlessService()
private let settings = SettingsService.shared
private let log = Logger(subsystem: "com.oai.oAI", category: "mcp")
private let readTimeout: TimeInterval = 15
private let uploadTimeout: TimeInterval = 60
private(set) var isConnected = false
// In-memory caches for ID name resolution
private var tagCache: [Int: String] = [:]
private var correspondentCache: [Int: String] = [:]
private var documentTypeCache: [Int: String] = [:]
private init() {}
// MARK: - Connection Test
func testConnection() async -> Result<String, Error> {
do {
let result = try await request(endpoint: "/api/documents/", queryParams: ["page_size": "1"])
if let count = result["count"] as? Int {
isConnected = true
return .success("Connected (\(count) document\(count == 1 ? "" : "s"))")
} else {
isConnected = true
return .success("Connected to Paperless-NGX")
}
} catch {
isConnected = false
return .failure(error)
}
}
// MARK: - Tool Schemas
func getToolSchemas() -> [Tool] {
return [
makeTool(
name: "paperless_search",
description: "Search for documents in Paperless-NGX by title, content, tags, or any text. Returns document metadata and a preview of OCR-extracted content. Use this to find invoices, contracts, letters, or any stored document.",
properties: [
"query": prop("string", "Search query — can be text from document content, title, correspondent name, or tag"),
"page": prop("number", "Page number for pagination (default: 1, each page has 25 results)")
],
required: ["query"]
),
makeTool(
name: "paperless_get_document",
description: "Get the full details and complete OCR-extracted text content of a specific Paperless-NGX document by ID. Use after paperless_search to read the full text of a document.",
properties: [
"document_id": prop("number", "The numeric ID of the document to retrieve")
],
required: ["document_id"]
),
makeTool(
name: "paperless_list_tags",
description: "List all tags defined in Paperless-NGX with their document counts.",
properties: [:],
required: []
),
makeTool(
name: "paperless_list_correspondents",
description: "List all correspondents (senders/recipients) defined in Paperless-NGX with their document counts.",
properties: [:],
required: []
),
makeTool(
name: "paperless_list_document_types",
description: "List all document types defined in Paperless-NGX with their document counts.",
properties: [:],
required: []
),
makeTool(
name: "paperless_upload_document",
description: "Upload a local file to Paperless-NGX for OCR processing and storage. Supports PDF, PNG, JPEG, TIFF, and other image formats.",
properties: [
"file_path": prop("string", "Absolute path to the local file to upload"),
"title": prop("string", "Optional title for the document"),
"tag_ids": prop("string", "Optional comma-separated tag IDs to assign (e.g. '1,3,7')")
],
required: ["file_path"]
)
]
}
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) async -> [String: Any] {
log.info("Executing Paperless tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
return ["error": "Invalid arguments JSON"]
}
do {
switch name {
case "paperless_search":
guard let query = args["query"] as? String else {
return ["error": "Missing required parameter: query"]
}
let page: Int
if let p = args["page"] as? Int { page = p }
else if let p = args["page"] as? Double { page = Int(p) }
else { page = 1 }
return try await searchDocuments(query: query, page: page)
case "paperless_get_document":
let docId: Int
if let id = args["document_id"] as? Int { docId = id }
else if let id = args["document_id"] as? Double { docId = Int(id) }
else { return ["error": "Missing or invalid parameter: document_id (expected integer)"] }
return try await getDocument(id: docId)
case "paperless_list_tags":
return try await listTags()
case "paperless_list_correspondents":
return try await listCorrespondents()
case "paperless_list_document_types":
return try await listDocumentTypes()
case "paperless_upload_document":
guard let filePath = args["file_path"] as? String else {
return ["error": "Missing required parameter: file_path"]
}
let title = args["title"] as? String
let tagIds = args["tag_ids"] as? String
return try await uploadDocument(filePath: filePath, title: title, tagIds: tagIds)
default:
return ["error": "Unknown Paperless tool: \(name)"]
}
} catch PaperlessError.notConfigured {
return ["error": "Paperless-NGX is not configured. Set your URL and API token in Settings > Paperless."]
} catch PaperlessError.unauthorized {
return ["error": "Invalid API token. Check your Paperless-NGX token in Settings > Paperless."]
} catch PaperlessError.httpError(let code, let msg) {
return ["error": "Paperless-NGX API error \(code): \(msg)"]
} catch {
return ["error": "Paperless error: \(error.localizedDescription)"]
}
}
// MARK: - API Operations
private func searchDocuments(query: String, page: Int) async throws -> [String: Any] {
await prefetchCaches()
let result = try await request(endpoint: "/api/documents/", queryParams: [
"query": query,
"page": String(page)
])
let total = result["count"] as? Int ?? 0
guard let rawResults = result["results"] as? [[String: Any]] else {
return ["total": total, "page": page, "results": []]
}
let formatted = rawResults.map { doc -> [String: Any] in
var item: [String: Any] = [:]
item["id"] = doc["id"] ?? 0
item["title"] = doc["title"] ?? "Untitled"
item["created"] = (doc["created"] as? String).map { String($0.prefix(10)) } ?? ""
if let corrId = doc["correspondent"] as? Int {
item["correspondent"] = correspondentCache[corrId] ?? "ID:\(corrId)"
}
if let dtId = doc["document_type"] as? Int {
item["document_type"] = documentTypeCache[dtId] ?? "ID:\(dtId)"
}
if let tagIds = doc["tags"] as? [Int] {
item["tags"] = tagIds.map { tagCache[$0] ?? "ID:\($0)" }
}
// Content preview capped at 500 chars
if let content = doc["content"] as? String, !content.isEmpty {
let preview = content.trimmingCharacters(in: .whitespacesAndNewlines)
item["content_preview"] = String(preview.prefix(500))
}
return item
}
return ["total": total, "page": page, "results": formatted]
}
private func getDocument(id: Int) async throws -> [String: Any] {
await prefetchCaches()
let doc = try await request(endpoint: "/api/documents/\(id)/")
var result: [String: Any] = [:]
result["id"] = doc["id"] ?? id
result["title"] = doc["title"] ?? "Untitled"
result["created"] = (doc["created"] as? String).map { String($0.prefix(10)) } ?? ""
result["added"] = (doc["added"] as? String).map { String($0.prefix(10)) } ?? ""
result["modified"] = (doc["modified"] as? String).map { String($0.prefix(10)) } ?? ""
if let corrId = doc["correspondent"] as? Int {
result["correspondent"] = correspondentCache[corrId] ?? "ID:\(corrId)"
}
if let dtId = doc["document_type"] as? Int {
result["document_type"] = documentTypeCache[dtId] ?? "ID:\(dtId)"
}
if let tagIds = doc["tags"] as? [Int] {
result["tags"] = tagIds.map { tagCache[$0] ?? "ID:\($0)" }
}
if let asn = doc["archive_serial_number"] as? String {
result["archive_serial_number"] = asn
}
// Full OCR content capped at 30,000 chars
if let content = doc["content"] as? String {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
result["content"] = String(trimmed.prefix(30_000))
result["content_length"] = trimmed.count
}
return result
}
private func listTags() async throws -> [String: Any] {
let result = try await request(endpoint: "/api/tags/", queryParams: ["page_size": "250"])
guard let items = result["results"] as? [[String: Any]] else {
return ["count": 0, "tags": []]
}
let formatted = items.map { tag -> [String: Any] in
["id": tag["id"] ?? 0, "name": tag["name"] ?? "Unknown", "count": tag["document_count"] ?? 0]
}
return ["count": formatted.count, "tags": formatted]
}
private func listCorrespondents() async throws -> [String: Any] {
let result = try await request(endpoint: "/api/correspondents/", queryParams: ["page_size": "250"])
guard let items = result["results"] as? [[String: Any]] else {
return ["count": 0, "correspondents": []]
}
let formatted = items.map { c -> [String: Any] in
["id": c["id"] ?? 0, "name": c["name"] ?? "Unknown", "count": c["document_count"] ?? 0]
}
return ["count": formatted.count, "correspondents": formatted]
}
private func listDocumentTypes() async throws -> [String: Any] {
let result = try await request(endpoint: "/api/document_types/", queryParams: ["page_size": "250"])
guard let items = result["results"] as? [[String: Any]] else {
return ["count": 0, "document_types": []]
}
let formatted = items.map { dt -> [String: Any] in
["id": dt["id"] ?? 0, "name": dt["name"] ?? "Unknown", "count": dt["document_count"] ?? 0]
}
return ["count": formatted.count, "document_types": formatted]
}
private func uploadDocument(filePath: String, title: String?, tagIds: String?) async throws -> [String: Any] {
let expanded = (filePath as NSString).expandingTildeInPath
let resolved = (expanded as NSString).standardizingPath
guard FileManager.default.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
guard let fileData = FileManager.default.contents(atPath: resolved) else {
return ["error": "Cannot read file: \(filePath)"]
}
let fileName = (resolved as NSString).lastPathComponent
guard let token = settings.paperlessAPIToken, !token.isEmpty else {
throw PaperlessError.notConfigured
}
let baseURL = settings.paperlessURL
guard !baseURL.isEmpty, let url = URL(string: baseURL + "/api/documents/post_document/") else {
throw PaperlessError.notConfigured
}
let boundary = "PaperlessBoundary\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))"
var body = Data()
func appendField(_ name: String, _ value: String) {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(value)\r\n".data(using: .utf8)!)
}
if let title = title, !title.isEmpty {
appendField("title", title)
}
if let tagIds = tagIds {
let ids = tagIds.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
for id in ids {
appendField("tags", String(id))
}
}
let mimeType = mimeTypeFor(fileName: fileName)
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"document\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(fileData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
var urlRequest = URLRequest(url: url, timeoutInterval: uploadTimeout)
urlRequest.httpMethod = "POST"
urlRequest.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = body
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw PaperlessError.httpError(0, "Invalid response")
}
if httpResponse.statusCode == 401 { throw PaperlessError.unauthorized }
if (200...299).contains(httpResponse.statusCode) {
return ["success": true, "message": "Document uploaded successfully. Paperless-NGX will process it shortly."]
}
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
throw PaperlessError.httpError(httpResponse.statusCode, msg)
}
// MARK: - Cache Prefetch
private func prefetchCaches() async {
if tagCache.isEmpty {
if let result = try? await request(endpoint: "/api/tags/", queryParams: ["page_size": "250"]),
let items = result["results"] as? [[String: Any]] {
for item in items {
if let id = item["id"] as? Int, let name = item["name"] as? String {
tagCache[id] = name
}
}
}
}
if correspondentCache.isEmpty {
if let result = try? await request(endpoint: "/api/correspondents/", queryParams: ["page_size": "250"]),
let items = result["results"] as? [[String: Any]] {
for item in items {
if let id = item["id"] as? Int, let name = item["name"] as? String {
correspondentCache[id] = name
}
}
}
}
if documentTypeCache.isEmpty {
if let result = try? await request(endpoint: "/api/document_types/", queryParams: ["page_size": "250"]),
let items = result["results"] as? [[String: Any]] {
for item in items {
if let id = item["id"] as? Int, let name = item["name"] as? String {
documentTypeCache[id] = name
}
}
}
}
}
// MARK: - HTTP Client
private func request(endpoint: String, queryParams: [String: String] = [:]) async throws -> [String: Any] {
guard let token = settings.paperlessAPIToken, !token.isEmpty else {
throw PaperlessError.notConfigured
}
let baseURL = settings.paperlessURL
guard !baseURL.isEmpty else { throw PaperlessError.notConfigured }
var urlString = baseURL + endpoint
if !queryParams.isEmpty {
var comps = URLComponents(string: urlString) ?? URLComponents()
comps.queryItems = queryParams.map { URLQueryItem(name: $0.key, value: $0.value) }
urlString = comps.url?.absoluteString ?? urlString
}
guard let url = URL(string: urlString) else {
throw PaperlessError.httpError(0, "Invalid URL: \(urlString)")
}
var urlRequest = URLRequest(url: url, timeoutInterval: readTimeout)
urlRequest.httpMethod = "GET"
urlRequest.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw PaperlessError.httpError(0, "Invalid response")
}
if httpResponse.statusCode == 401 { throw PaperlessError.unauthorized }
guard (200...299).contains(httpResponse.statusCode) else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
throw PaperlessError.httpError(httpResponse.statusCode, msg)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return [:]
}
return json
} catch let error as PaperlessError {
throw error
} catch {
throw PaperlessError.httpError(0, error.localizedDescription)
}
}
// MARK: - Helpers
private func mimeTypeFor(fileName: String) -> String {
let ext = (fileName as NSString).pathExtension.lowercased()
switch ext {
case "pdf": return "application/pdf"
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "tiff", "tif": return "image/tiff"
case "gif": return "image/gif"
case "webp": return "image/webp"
default: return "application/octet-stream"
}
}
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
Tool(
type: "function",
function: Tool.Function(
name: name,
description: description,
parameters: Tool.Function.Parameters(
type: "object",
properties: properties,
required: required
)
)
)
}
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
}
}
// MARK: - Error Types
enum PaperlessError: LocalizedError {
case notConfigured
case unauthorized
case httpError(Int, String)
var errorDescription: String? {
switch self {
case .notConfigured:
return "Paperless-NGX is not configured. Set your URL and API token in Settings > Paperless."
case .unauthorized:
return "Invalid API token. Check your Paperless-NGX token in Settings > Paperless."
case .httpError(let code, let msg):
return "Paperless-NGX API error \(code): \(msg)"
}
}
}

View File

@@ -42,6 +42,7 @@ class SettingsService {
static let googleAPIKey = "googleAPIKey"
static let googleSearchEngineID = "googleSearchEngineID"
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
static let paperlessAPIToken = "paperlessAPIToken"
}
// Old keychain keys (for migration only)
@@ -446,6 +447,83 @@ class SettingsService {
return !key.isEmpty
}
// MARK: - Bash Execution Settings
var bashEnabled: Bool {
get { cache["bashEnabled"] == "true" }
set {
cache["bashEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "bashEnabled", value: String(newValue))
}
}
var bashRequireApproval: Bool {
get { cache["bashRequireApproval"].map { $0 == "true" } ?? true }
set {
cache["bashRequireApproval"] = String(newValue)
DatabaseService.shared.setSetting(key: "bashRequireApproval", value: String(newValue))
}
}
var bashWorkingDirectory: String {
get { cache["bashWorkingDirectory"] ?? "~" }
set {
cache["bashWorkingDirectory"] = newValue
DatabaseService.shared.setSetting(key: "bashWorkingDirectory", value: newValue)
}
}
var bashTimeout: Int {
get { cache["bashTimeout"].flatMap(Int.init) ?? 30 }
set {
cache["bashTimeout"] = String(newValue)
DatabaseService.shared.setSetting(key: "bashTimeout", value: String(newValue))
}
}
// MARK: - Paperless-NGX Settings
var paperlessEnabled: Bool {
get { cache["paperlessEnabled"] == "true" }
set {
cache["paperlessEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "paperlessEnabled", value: String(newValue))
}
}
var paperlessURL: String {
get { cache["paperlessURL"] ?? "" }
set {
var trimmed = newValue.trimmingCharacters(in: .whitespaces)
// Remove trailing slash for consistency
while trimmed.hasSuffix("/") { trimmed = String(trimmed.dropLast()) }
if trimmed.isEmpty {
cache.removeValue(forKey: "paperlessURL")
DatabaseService.shared.deleteSetting(key: "paperlessURL")
} else {
cache["paperlessURL"] = trimmed
DatabaseService.shared.setSetting(key: "paperlessURL", value: trimmed)
}
}
}
var paperlessAPIToken: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.paperlessAPIToken) }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.paperlessAPIToken, value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.paperlessAPIToken)
}
}
}
var paperlessConfigured: Bool {
guard !paperlessURL.isEmpty else { return false }
guard let token = paperlessAPIToken else { return false }
return !token.isEmpty
}
// MARK: - Search Settings
var searchProvider: Settings.SearchProvider {
@@ -875,6 +953,15 @@ class SettingsService {
return true
}
// MARK: - Cache Reload
/// Replace the in-memory cache with a fresh read from the database.
/// Called by BackupService after restoring a backup.
func reloadFromDatabase() {
cache = (try? DatabaseService.shared.loadAllSettings()) ?? [:]
Log.settings.info("Settings cache reloaded (\(self.cache.count) entries)")
}
// MARK: - UserDefaults Migration
private func migrateFromUserDefaultsIfNeeded() {

View File

@@ -36,37 +36,44 @@ final class UpdateCheckService {
var updateAvailable: Bool = false
var latestVersion: String? = nil
private let apiURL = "https://gitlab.pm/api/v4/projects/rune%2Foai-swift/releases"
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
private init() {}
/// Kick off a background update check. Silently does nothing on failure.
func checkForUpdates() {
Task {
await performCheck()
Task.detached(priority: .background) {
await self.performCheck()
}
}
@MainActor
private func performCheck() async {
guard let url = URL(string: apiURL) else { return }
var request = URLRequest(url: url)
request.timeoutInterval = 10
guard let (data, _) = try? await URLSession.shared.data(for: request) else { return }
guard let releases = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let latest = releases.first,
let tagName = latest["tag_name"] as? String else { return }
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
Log.ui.warning("UpdateCheck: network request failed")
return
}
guard let release = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let tagName = release["tag_name"] as? String else {
Log.ui.warning("UpdateCheck: unexpected API response — \(String(data: data, encoding: .utf8) ?? "<binary>")")
return
}
// Strip leading "v" from tag (e.g. "v2.3.1" "2.3.1")
let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
if isNewer(latestVer, than: currentVer) {
self.latestVersion = latestVer
self.updateAvailable = true
await MainActor.run {
self.latestVersion = latestVer
self.updateAvailable = true
}
}
}

View File

@@ -56,7 +56,7 @@ class ChatViewModel {
var modelInfoTarget: ModelInfo? = nil
var commandHistory: [String] = []
var historyIndex: Int = 0
var isAutoContinuing: Bool = false
private var silentContinuePrompt: String? = nil
// Save tracking
var currentConversationId: UUID? = nil
@@ -67,7 +67,6 @@ class ChatViewModel {
let chatCount = messages.filter { $0.role != .system }.count
return chatCount > 0 && chatCount != savedMessageCount
}
var autoContinueCountdown: Int = 0
// MARK: - Auto-Save Tracking
@@ -78,7 +77,6 @@ class ChatViewModel {
// MARK: - Private State
private var streamingTask: Task<Void, Never>?
private var autoContinueTask: Task<Void, Never>?
private let settings = SettingsService.shared
private let providerRegistry = ProviderRegistry.shared
@@ -164,6 +162,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Otherwise, build the prompt: default + conditional sections + custom (if append mode)
var prompt = defaultSystemPrompt
// Prepend model identity to prevent models trained on Claude data from misidentifying themselves.
// Skip for direct Anthropic/OpenAI providers those models know who they are.
if let model = selectedModel,
currentProvider != .anthropic && currentProvider != .openai {
prompt = "You are \(model.name).\n\n" + prompt
}
// Add tool-specific guidelines if MCP is enabled (tools are available)
if mcpEnabled {
prompt += toolUsageGuidelines
@@ -290,6 +295,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
func sendMessage() {
guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
// If already generating, cancel first new message becomes a followup in context
if isGenerating { cancelGeneration() }
let trimmedInput = inputText.trimmingCharacters(in: .whitespaces)
// Handle slash escape: "//" becomes "/"
@@ -347,59 +355,21 @@ Don't narrate future actions ("Let me...") - just use the tools.
streamingTask?.cancel()
streamingTask = nil
isGenerating = false
cancelAutoContinue() // Also cancel any pending auto-continue
silentContinuePrompt = nil
}
func startAutoContinue() {
isAutoContinuing = true
autoContinueCountdown = 5
autoContinueTask = Task { @MainActor in
// Countdown from 5 to 1
for i in (1...5).reversed() {
if Task.isCancelled {
isAutoContinuing = false
return
}
autoContinueCountdown = i
try? await Task.sleep(for: .seconds(1))
}
// Continue the task
isAutoContinuing = false
autoContinueCountdown = 0
let continuePrompt = "Please continue from where you left off."
// Add user message
let userMessage = Message(
role: .user,
content: continuePrompt,
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil,
responseTime: nil,
wasInterrupted: false,
modelId: selectedModel?.id
)
messages.append(userMessage)
// Continue generation
generateAIResponse(to: continuePrompt, attachments: nil)
showSystemMessage("↩ Continuing…")
silentContinuePrompt = "Please continue from where you left off."
Task { @MainActor in
generateAIResponse(to: "", attachments: nil)
}
}
func cancelAutoContinue() {
autoContinueTask?.cancel()
autoContinueTask = nil
isAutoContinuing = false
autoContinueCountdown = 0
}
func clearChat() {
messages.removeAll()
sessionStats.reset()
MCPService.shared.resetBashSessionApproval()
showSystemMessage("Chat cleared")
}
@@ -412,8 +382,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages.removeAll()
sessionStats.reset()
MCPService.shared.resetBashSessionApproval()
messages = loadedMessages
// Track identity so S can re-save under the same name
currentConversationId = conversation.id
currentConversationName = conversation.name
savedMessageCount = loadedMessages.filter { $0.role != .system }.count
// Rebuild session stats from loaded messages
for msg in loadedMessages {
sessionStats.addMessage(
@@ -435,6 +411,17 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
/// Infer which provider owns a given model ID based on naming conventions.
/// Update the selected model and keep currentProvider + settings in sync.
/// Call this whenever the user picks a model in the model selector.
func selectModel(_ model: ModelInfo) {
let newProvider = inferProvider(from: model.id) ?? currentProvider
selectedModel = model
currentProvider = newProvider
settings.defaultModel = model.id
settings.defaultProvider = newProvider
MCPService.shared.resetBashSessionApproval()
}
private func inferProvider(from modelId: String) -> Settings.Provider? {
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
if modelId.contains("/") { return .openrouter }
@@ -767,8 +754,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
let mcp = MCPService.shared
let mcpActive = mcpEnabled || settings.mcpEnabled
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
let bashActive = settings.bashEnabled
let modelSupportTools = selectedModel?.capabilities.tools ?? false
if modelSupportTools && (anytypeActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
if modelSupportTools && (anytypeActive || bashActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
generateAIResponseWithTools(provider: provider, modelId: modelId)
return
}
@@ -881,7 +869,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
temperature: chatRequest.temperature,
imageGeneration: true
)
let response = try await provider.chat(request: nonStreamRequest)
let response = try await withOverloadedRetry { try await provider.chat(request: nonStreamRequest) }
let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
@@ -1220,7 +1208,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
let startTime = Date()
var wasCancelled = false
do {
let tools = mcp.getToolSchemas()
// Include web_search tool when online mode is on (not needed for OpenRouter it handles search via :online suffix)
let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter)
// Apply :online suffix for OpenRouter when online mode is active
var effectiveModelId = modelId
@@ -1259,20 +1248,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
? messages.filter { $0.role != .system }
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
// Web search via our WebSearchService
// Append results to last user message content (matching Python oAI approach)
if onlineMode && currentProvider != .openrouter {
if let lastUserIdx = messagesToSend.lastIndex(where: { $0.role == .user }) {
Log.search.info("Running web search for tool-aware path (\(currentProvider.displayName))")
let results = await WebSearchService.shared.search(query: messagesToSend[lastUserIdx].content)
if !results.isEmpty {
let searchContext = "\n\n\(WebSearchService.shared.formatResults(results))\n\nPlease use the above web search results to help answer the user's question."
messagesToSend[lastUserIdx].content += searchContext
Log.search.info("Injected \(results.count) search results into user message")
}
}
}
let systemPrompt: [String: Any] = [
"role": "system",
"content": systemContent
@@ -1281,7 +1256,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments {
var contentArray: [[String: Any]] = [["type": "text", "text": msg.content]]
var contentArray: [[String: Any]] = []
if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
contentArray.append(["type": "text", "text": msg.content])
}
for attachment in msg.attachments ?? [] {
guard let data = attachment.data else { continue }
switch attachment.type {
@@ -1297,7 +1275,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
return ["role": msg.role.rawValue, "content": contentArray]
}
return ["role": msg.role.rawValue, "content": msg.content]
let content = msg.content.trimmingCharacters(in: .whitespacesAndNewlines)
return ["role": msg.role.rawValue, "content": content.isEmpty ? "[Image]" : content]
}
// If this is a silent auto-continue, inject the prompt into the API call only
if let continuePrompt = silentContinuePrompt {
apiMessages.append(["role": "user", "content": continuePrompt])
silentContinuePrompt = nil
}
let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit
@@ -1311,13 +1296,15 @@ Don't narrate future actions ("Let me...") - just use the tools.
break
}
let response = try await provider.chatWithToolMessages(
model: effectiveModelId,
messages: apiMessages,
tools: tools,
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
temperature: settings.temperature > 0 ? settings.temperature : nil
)
let response = try await withOverloadedRetry {
try await provider.chatWithToolMessages(
model: effectiveModelId,
messages: apiMessages,
tools: tools,
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
temperature: settings.temperature > 0 ? settings.temperature : nil
)
}
if let usage = response.usage { totalUsage = usage }
@@ -1336,7 +1323,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Show what tools the model is calling
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
showSystemMessage("🔧 Calling: \(toolNames)")
let toolMsgId = showSystemMessage("🔧 Calling: \(toolNames)")
// Initialise detail entries with inputs (results fill in below)
var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
}
updateToolCallMessage(id: toolMsgId, details: toolDetails)
let usingTextCalls = !textCalls.isEmpty
@@ -1365,7 +1358,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Execute each tool and append results
var toolResultLines: [String] = []
for tc in toolCalls {
for (i, tc) in toolCalls.enumerated() {
if Task.isCancelled {
wasCancelled = true
break
@@ -1387,6 +1380,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
resultJSON = "{\"error\": \"Failed to serialize result\"}"
}
// Update the detail entry with the result so the UI can show it
toolDetails[i].result = resultJSON
updateToolCallMessage(id: toolMsgId, details: toolDetails)
if usingTextCalls {
// Inject results as a user message for text-call models
toolResultLines.append("Tool result for \(tc.functionName):\n\(resultJSON)")
@@ -1487,7 +1484,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
}
private func showSystemMessage(_ text: String) {
@discardableResult
private func showSystemMessage(_ text: String) -> UUID {
let message = Message(
role: .system,
content: text,
@@ -1497,6 +1495,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
attachments: nil
)
messages.append(message)
return message.id
}
private func updateToolCallMessage(id: UUID, details: [ToolCallDetail]) {
if let idx = messages.firstIndex(where: { $0.id == id }) {
messages[idx].toolCalls = details
}
}
// MARK: - Error Helpers
@@ -1532,6 +1537,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
return "Server error. The provider may be experiencing issues. Try again shortly."
}
// Overloaded / 529
if desc.contains("529") || desc.lowercased().contains("overloaded") {
return "API is overloaded. Please try again shortly."
}
// Timeout patterns
if desc.lowercased().contains("timed out") || desc.lowercased().contains("timeout") {
return "Request timed out. Try a shorter message or different model."
@@ -1541,6 +1551,30 @@ Don't narrate future actions ("Let me...") - just use the tools.
return desc
}
/// Retry an async operation on overloaded (529) errors with exponential backoff.
private func withOverloadedRetry<T>(maxAttempts: Int = 4, operation: () async throws -> T) async throws -> T {
var attempt = 0
while true {
do {
return try await operation()
} catch {
let desc = error.localizedDescription
let isOverloaded = desc.contains("529") || desc.lowercased().contains("overloaded")
attempt += 1
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 {
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
}
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
} else {
throw error
}
}
}
}
// MARK: - Helpers
private func showModelInfo(_ model: ModelInfo) {

View File

@@ -81,40 +81,6 @@ struct ChatView: View {
}
}
// Auto-continue countdown banner
if viewModel.isAutoContinuing {
HStack(spacing: 12) {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(0.7)
VStack(alignment: .leading, spacing: 2) {
Text(ThinkingVerbs.random())
.font(.system(size: 13, weight: .medium))
.foregroundColor(.oaiPrimary)
Text("Continuing in \(viewModel.autoContinueCountdown)s")
.font(.system(size: 11))
.foregroundColor(.oaiSecondary)
}
Spacer()
Button("Cancel") {
viewModel.cancelAutoContinue()
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.red)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color.blue.opacity(0.1))
.overlay(
Rectangle()
.stroke(Color.blue.opacity(0.3), lineWidth: 1)
)
}
// Input bar
InputBar(
text: $viewModel.inputText,
@@ -140,6 +106,16 @@ struct ChatView: View {
.sheet(isPresented: $viewModel.showSkills) {
AgentSkillsView()
}
.sheet(item: Binding(
get: { MCPService.shared.pendingBashCommand },
set: { _ in }
)) { pending in
BashApprovalSheet(
pending: pending,
onApprove: { forSession in MCPService.shared.approvePendingBashCommand(forSession: forSession) },
onDeny: { MCPService.shared.denyPendingBashCommand() }
)
}
}
}

View File

@@ -63,8 +63,7 @@ struct ContentView: View {
selectedModel: chatViewModel.selectedModel,
onSelect: { model in
let oldModel = chatViewModel.selectedModel
chatViewModel.selectedModel = model
SettingsService.shared.defaultModel = model.id
chatViewModel.selectModel(model)
chatViewModel.showModelSelector = false
// Trigger auto-save on model switch
Task {

View File

@@ -87,7 +87,7 @@ struct FooterView: View {
// Shortcuts hint
#if os(macOS)
Text("⌘N New • ⌘M Model • ⌘S Save")
Text("⌘N New • ⌘M Model • ⌘S Save")
.font(.caption2)
.foregroundColor(.oaiSecondary)
#endif
@@ -221,7 +221,7 @@ struct SyncStatusFooter: View {
}
private func updateSyncStatus() {
if let error = gitSync.lastSyncError {
if gitSync.lastSyncError != nil {
syncText = "Sync Error"
syncColor = .red
} else if gitSync.isSyncing {

View File

@@ -33,6 +33,8 @@ struct MessageRow: View {
let viewModel: ChatViewModel?
private let settings = SettingsService.shared
@State private var isExpanded = false
#if os(macOS)
@State private var isHovering = false
@State private var showCopied = false
@@ -82,8 +84,8 @@ struct MessageRow: View {
.help("Star this message to always include it in context")
}
// Copy button (assistant messages only, visible on hover)
if message.role == .assistant && isHovering && !message.content.isEmpty {
// Copy button (user + assistant messages, visible on hover)
if (message.role == .assistant || message.role == .user) && isHovering && !message.content.isEmpty {
Button(action: copyContent) {
HStack(spacing: 3) {
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
@@ -188,27 +190,126 @@ struct MessageRow: View {
@ViewBuilder
private var compactSystemMessage: some View {
HStack(spacing: 8) {
Image(systemName: "wrench.and.screwdriver")
.font(.system(size: 11))
.foregroundColor(.secondary)
let expandable = message.toolCalls != nil
VStack(alignment: .leading, spacing: 0) {
Button(action: {
if expandable {
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
}
}) {
HStack(spacing: 8) {
Image(systemName: "wrench.and.screwdriver")
.font(.system(size: 11))
.foregroundColor(.secondary)
Text(message.content)
.font(.system(size: 11))
.foregroundColor(.secondary)
Text(message.content)
.font(.system(size: 11))
.foregroundColor(.secondary)
Spacer()
Spacer()
Text(message.timestamp, style: .time)
.font(.system(size: 10))
.foregroundColor(.secondary.opacity(0.7))
if expandable {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 9, weight: .medium))
.foregroundColor(.secondary.opacity(0.5))
}
Text(message.timestamp, style: .time)
.font(.system(size: 10))
.foregroundColor(.secondary.opacity(0.7))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded, let calls = message.toolCalls {
Divider()
.padding(.horizontal, 8)
toolCallsDetailView(calls)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.secondary.opacity(0.08))
.cornerRadius(6)
}
@ViewBuilder
private func toolCallsDetailView(_ calls: [ToolCallDetail]) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(calls.indices, id: \.self) { i in
let call = calls[i]
VStack(alignment: .leading, spacing: 6) {
// Tool name + status
HStack(spacing: 6) {
Image(systemName: "function")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
Text(call.name)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
Spacer()
if call.result == nil {
ProgressView()
.scaleEffect(0.5)
.frame(width: 12, height: 12)
} else {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 10))
.foregroundColor(.green.opacity(0.8))
}
}
// Input
if !call.input.isEmpty && call.input != "{}" {
toolDetailSection(label: "Input", text: prettyJSON(call.input), maxHeight: 100)
}
// Result
if let result = call.result {
toolDetailSection(label: "Result", text: prettyJSON(result), maxHeight: 180)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
if i < calls.count - 1 {
Divider().padding(.horizontal, 12)
}
}
}
}
@ViewBuilder
private func toolDetailSection(label: String, text: String, maxHeight: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased())
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.secondary.opacity(0.6))
ScrollView([.vertical, .horizontal]) {
Text(text)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxHeight: maxHeight)
.background(Color.secondary.opacity(0.06))
.cornerRadius(4)
}
}
private func prettyJSON(_ raw: String) -> String {
guard let data = raw.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
let str = String(data: pretty, encoding: .utf8) else {
return raw
}
return str
}
// MARK: - Message Content
@ViewBuilder

View File

@@ -0,0 +1,128 @@
//
// BashApprovalSheet.swift
// oAI
//
// Approval UI for AI-requested bash commands
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import SwiftUI
struct BashApprovalSheet: View {
let pending: MCPService.PendingBashCommand
let onApprove: (_ forSession: Bool) -> Void
let onDeny: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 20) {
// Header
HStack(spacing: 12) {
Image(systemName: "terminal.fill")
.font(.title2)
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Allow Shell Command?")
.font(.system(size: 17, weight: .semibold))
Text("The AI wants to run the following command")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
Spacer()
}
// Command display
VStack(alignment: .leading, spacing: 6) {
Text("COMMAND")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
ScrollView {
Text(pending.command)
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.padding(12)
}
.frame(maxHeight: 180)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
}
// Working directory
HStack(spacing: 6) {
Image(systemName: "folder")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text("Working directory:")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(pending.workingDirectory)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
}
// Warning banner
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.system(size: 13))
.padding(.top, 1)
Text("Shell commands have full access to your system. Only approve commands you understand and trust.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(10)
.background(Color.orange.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
// Buttons
HStack(spacing: 8) {
Button("Deny") {
onDeny()
}
.buttonStyle(.bordered)
.tint(.red)
.keyboardShortcut(.escape, modifiers: [])
Spacer()
Button("Allow Once") {
onApprove(false)
}
.buttonStyle(.bordered)
.tint(.orange)
Button("Allow for Session") {
onApprove(true)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.keyboardShortcut(.return, modifiers: [])
}
}
.padding(24)
.frame(width: 480)
}
}

View File

@@ -33,6 +33,7 @@ struct CommandDetail: Identifiable {
let brief: String
let detail: String
let examples: [String]
var shortcut: String? = nil
}
struct CommandCategory: Identifiable {
@@ -50,13 +51,15 @@ private let helpCategories: [CommandCategory] = [
command: "/history",
brief: "View command history",
detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.",
examples: ["/history"]
examples: ["/history"],
shortcut: "⌘H"
),
CommandDetail(
command: "/clear",
brief: "Clear chat history",
detail: "Removes all messages from the current session and resets the conversation. This does not delete saved conversations.",
examples: ["/clear"]
examples: ["/clear"],
shortcut: "⌘K"
),
CommandDetail(
command: "/retry",
@@ -82,7 +85,8 @@ private let helpCategories: [CommandCategory] = [
command: "/model",
brief: "Select AI model",
detail: "Opens the model selector to browse and choose from available models. The list depends on your active provider and API key.",
examples: ["/model"]
examples: ["/model"],
shortcut: "⌘M"
),
CommandDetail(
command: "/provider [name]",
@@ -108,19 +112,22 @@ private let helpCategories: [CommandCategory] = [
command: "/save <name>",
brief: "Save current conversation",
detail: "Saves all messages in the current session under the given name. You can load it later to continue the conversation.",
examples: ["/save my-project-chat", "/save debug session"]
examples: ["/save my-project-chat", "/save debug session"],
shortcut: "⌘S"
),
CommandDetail(
command: "/load",
brief: "Load saved conversation",
detail: "Opens the conversation list to browse and load a previously saved conversation. Replaces the current chat.",
examples: ["/load"]
examples: ["/load"],
shortcut: "⌘L"
),
CommandDetail(
command: "/list",
brief: "List saved conversations",
detail: "Opens the conversation list showing all saved conversations with their dates and message counts.",
examples: ["/list"]
examples: ["/list"],
shortcut: "⌘L"
),
CommandDetail(
command: "/delete <name>",
@@ -178,13 +185,15 @@ private let helpCategories: [CommandCategory] = [
command: "/config",
brief: "Open settings",
detail: "Opens the settings panel where you can configure providers, API keys, MCP permissions, appearance, and more. Also available via /settings.",
examples: ["/config", "/settings"]
examples: ["/config", "/settings"],
shortcut: "⌘,"
),
CommandDetail(
command: "/stats",
brief: "Show session statistics",
detail: "Displays statistics for the current session: message count, total tokens used, estimated cost, and session duration.",
examples: ["/stats"]
examples: ["/stats"],
shortcut: "⌘⇧S"
),
]),
]
@@ -461,6 +470,15 @@ private struct CommandRow: View {
Spacer()
if let shortcut = command.shortcut {
Text(shortcut)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)

File diff suppressed because it is too large Load Diff

View File

@@ -84,6 +84,9 @@ struct oAIApp: App {
CommandGroup(replacing: .newItem) {
Button("New Chat") { chatViewModel.newConversation() }
.keyboardShortcut("n", modifiers: .command)
Button("Clear Chat") { chatViewModel.clearChat() }
.keyboardShortcut("k", modifiers: .command)
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
}
CommandGroup(after: .newItem) {
@@ -93,8 +96,10 @@ struct oAIApp: App {
CommandGroup(replacing: .saveItem) {
Button("Save Chat") { chatViewModel.saveFromMenu() }
.keyboardShortcut("s", modifiers: [.command, .shift])
.keyboardShortcut("s", modifiers: .command)
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Stats") { chatViewModel.showStats = true }
.keyboardShortcut("s", modifiers: [.command, .shift])
}
CommandGroup(after: .importExport) {