Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3997f3feee | |||
| 914d608d35 | |||
| 11017ee7fa | |||
| d386888359 | |||
| 079eccbc4e | |||
| 56f79a690e | |||
| 41185cc08b | |||
| 5f9631077b | |||
| 4acf538d8f | |||
| 979747c1ea |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -119,3 +119,4 @@ GIT_SYNC_PHASE1_COMPLETE.md
|
||||
build-dmg.sh
|
||||
.claude/
|
||||
*.sh
|
||||
RELEASE_NOTES.md
|
||||
|
||||
44
README.md
44
README.md
@@ -8,9 +8,8 @@ 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
|
||||
- **Google** - Gemini models with native integration
|
||||
- **Ollama** - Local model inference for privacy
|
||||
|
||||
### 💬 Core Chat Capabilities
|
||||
@@ -71,12 +70,43 @@ Automated email responses powered by AI:
|
||||
|
||||
## Installation
|
||||
|
||||
Not available.
|
||||
### Download
|
||||
|
||||
Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-swift/releases). Two builds are available:
|
||||
|
||||
- **oAI-x.x.x-AppleSilicon.dmg** — for Macs with an Apple Silicon chip (M1 and later)
|
||||
- **oAI-x.x.x-Universal.dmg** — runs natively on both Apple Silicon and Intel Macs
|
||||
|
||||
### Installing from DMG
|
||||
|
||||
1. Open the downloaded `.dmg` file
|
||||
2. Drag **oAI.app** into the **Applications** folder
|
||||
3. Eject the DMG
|
||||
4. Launch oAI from Applications or Spotlight
|
||||
|
||||
### First Launch — Gatekeeper Warning
|
||||
|
||||
oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan — the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified."
|
||||
|
||||
To open the app, you have two options:
|
||||
|
||||
**Option A — Right-click to open (quickest):**
|
||||
1. Right-click (or Control-click) `oAI.app` in Applications
|
||||
2. Select **Open** from the context menu
|
||||
3. Click **Open** in the dialog that appears
|
||||
4. After doing this once, the app opens normally from then on
|
||||
|
||||
**Option B — Remove the quarantine flag via Terminal:**
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/oAI.app
|
||||
```
|
||||
|
||||
This command removes the quarantine attribute that macOS attaches to files downloaded from the internet. The `-d` flag deletes the attribute, `-r` applies it recursively to the app bundle. Once removed, macOS no longer blocks the app from launching.
|
||||
|
||||
### Requirements
|
||||
- macOS 14.0+
|
||||
- Xcode 15.0+
|
||||
- Swift 5.9+
|
||||
- macOS 14.0 (Sonoma) or later
|
||||
- An API key for at least one supported provider (OpenRouter, Anthropic, OpenAI, or Google), or Ollama running locally
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -85,8 +115,8 @@ Add your API keys in Settings (⌘,) → General tab:
|
||||
- **OpenAI** - Get from [OpenAI Platform](https://platform.openai.com/api-keys)
|
||||
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth
|
||||
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
|
||||
- **Google** - Get from [Google AI Studio](https://makersuite.google.com/app/apikey)
|
||||
- **Ollama** - Base URL (default: http://localhost:11434)
|
||||
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) — not a chat provider
|
||||
|
||||
### Essential Settings
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 551 KiB |
@@ -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.3;
|
||||
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.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
496
oAI/Services/PaperlessService.swift
Normal file
496
oAI/Services/PaperlessService.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
100
oAI/Services/UpdateCheckService.swift
Normal file
100
oAI/Services/UpdateCheckService.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// UpdateCheckService.swift
|
||||
// oAI
|
||||
//
|
||||
// Checks for new releases on GitLab and surfaces an update badge in the footer
|
||||
//
|
||||
// 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
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class UpdateCheckService {
|
||||
static let shared = UpdateCheckService()
|
||||
|
||||
var updateAvailable: Bool = false
|
||||
var latestVersion: String? = nil
|
||||
|
||||
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
|
||||
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Kick off a background update check. Silently does nothing on failure.
|
||||
func checkForUpdates() {
|
||||
Task.detached(priority: .background) {
|
||||
await self.performCheck()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
await MainActor.run {
|
||||
self.latestVersion = latestVer
|
||||
self.updateAvailable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic version comparison — returns true if `version` is newer than `current`.
|
||||
private func isNewer(_ version: String, than current: String) -> Bool {
|
||||
let lhs = version.split(separator: ".").compactMap { Int($0) }
|
||||
let rhs = current.split(separator: ".").compactMap { Int($0) }
|
||||
let count = max(lhs.count, rhs.count)
|
||||
for i in 0..<count {
|
||||
let l = i < lhs.count ? lhs[i] : 0
|
||||
let r = i < rhs.count ? rhs[i] : 0
|
||||
if l > r { return true }
|
||||
if l < r { return false }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Open the GitLab releases page in the default browser.
|
||||
func openReleasesPage() {
|
||||
#if os(macOS)
|
||||
NSWorkspace.shared.open(releasesURL)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -347,59 +352,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
|
||||
showSystemMessage("↩ Continuing…")
|
||||
silentContinuePrompt = "Please continue from where you left off."
|
||||
Task { @MainActor in
|
||||
generateAIResponse(to: "", attachments: nil)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelAutoContinue() {
|
||||
autoContinueTask?.cancel()
|
||||
autoContinueTask = nil
|
||||
isAutoContinuing = false
|
||||
autoContinueCountdown = 0
|
||||
}
|
||||
|
||||
func clearChat() {
|
||||
messages.removeAll()
|
||||
sessionStats.reset()
|
||||
MCPService.shared.resetBashSessionApproval()
|
||||
showSystemMessage("Chat cleared")
|
||||
}
|
||||
|
||||
@@ -412,6 +379,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
messages.removeAll()
|
||||
sessionStats.reset()
|
||||
MCPService.shared.resetBashSessionApproval()
|
||||
messages = loadedMessages
|
||||
|
||||
// Rebuild session stats from loaded messages
|
||||
@@ -435,6 +403,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 +746,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 +861,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 +1200,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 +1240,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
|
||||
@@ -1300,6 +1267,12 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
return ["role": msg.role.rawValue, "content": msg.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
|
||||
var finalContent = ""
|
||||
var totalUsage: ChatResponse.Usage?
|
||||
@@ -1311,13 +1284,15 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
break
|
||||
}
|
||||
|
||||
let response = try await provider.chatWithToolMessages(
|
||||
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 }
|
||||
|
||||
@@ -1532,6 +1507,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 +1521,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) {
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -80,6 +80,11 @@ struct FooterView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Update available badge
|
||||
#if os(macOS)
|
||||
UpdateBadge()
|
||||
#endif
|
||||
|
||||
// Shortcuts hint
|
||||
#if os(macOS)
|
||||
Text("⌘N New • ⌘M Model • ⌘⇧S Save")
|
||||
@@ -216,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 {
|
||||
@@ -254,6 +259,32 @@ struct SyncStatusFooter: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateBadge: View {
|
||||
private let updater = UpdateCheckService.shared
|
||||
private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
||||
|
||||
var body: some View {
|
||||
if updater.updateAvailable {
|
||||
Button(action: { updater.openReleasesPage() }) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 10))
|
||||
Text("Update Available\(updater.latestVersion.map { " (v\($0))" } ?? "")")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("A new version is available — click to open the releases page")
|
||||
} else {
|
||||
Text("v\(currentVersion)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let stats = SessionStats(
|
||||
totalInputTokens: 1250,
|
||||
|
||||
128
oAI/Views/Screens/BashApprovalSheet.swift
Normal file
128
oAI/Views/Screens/BashApprovalSheet.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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]",
|
||||
@@ -114,13 +118,15 @@ private let helpCategories: [CommandCategory] = [
|
||||
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,7 +184,8 @@ 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",
|
||||
@@ -461,6 +468,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
@@ -41,6 +41,9 @@ struct oAIApp: App {
|
||||
Task {
|
||||
await GitSyncService.shared.syncOnStartup()
|
||||
}
|
||||
|
||||
// Check for updates in the background
|
||||
UpdateCheckService.shared.checkForUpdates()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
#!/usr/bin/swift
|
||||
//
|
||||
// Git Sync Phase 1 Validation Script
|
||||
// Tests integration without requiring git repository
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
print("🧪 Git Sync Phase 1 Validation")
|
||||
print("================================\n")
|
||||
|
||||
var passCount = 0
|
||||
var failCount = 0
|
||||
|
||||
func test(_ name: String, _ block: () throws -> Bool) {
|
||||
do {
|
||||
if try block() {
|
||||
print("✅ \(name)")
|
||||
passCount += 1
|
||||
} else {
|
||||
print("❌ \(name)")
|
||||
failCount += 1
|
||||
}
|
||||
} catch {
|
||||
print("❌ \(name) - Error: \(error)")
|
||||
failCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Test 1: SyncModels.swift exists and has correct structure
|
||||
test("SyncModels.swift exists") {
|
||||
let path = "oAI/Models/SyncModels.swift"
|
||||
return FileManager.default.fileExists(atPath: path)
|
||||
}
|
||||
|
||||
test("SyncModels.swift contains SyncAuthMethod enum") {
|
||||
let path = "oAI/Models/SyncModels.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("enum SyncAuthMethod") &&
|
||||
content.contains("case ssh") &&
|
||||
content.contains("case password") &&
|
||||
content.contains("case token")
|
||||
}
|
||||
|
||||
test("SyncModels.swift contains SyncError enum") {
|
||||
let path = "oAI/Models/SyncModels.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("enum SyncError") &&
|
||||
content.contains("case notConfigured") &&
|
||||
content.contains("case secretsDetected")
|
||||
}
|
||||
|
||||
test("SyncModels.swift contains SyncStatus struct") {
|
||||
let path = "oAI/Models/SyncModels.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("struct SyncStatus") &&
|
||||
content.contains("var lastSyncTime") &&
|
||||
content.contains("var isCloned")
|
||||
}
|
||||
|
||||
test("SyncModels.swift contains ConversationExport struct") {
|
||||
let path = "oAI/Models/SyncModels.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("struct ConversationExport") &&
|
||||
content.contains("func toMarkdown()")
|
||||
}
|
||||
|
||||
// Test 2: GitSyncService.swift exists and has correct structure
|
||||
test("GitSyncService.swift exists") {
|
||||
let path = "oAI/Services/GitSyncService.swift"
|
||||
return FileManager.default.fileExists(atPath: path)
|
||||
}
|
||||
|
||||
test("GitSyncService.swift has singleton pattern") {
|
||||
let path = "oAI/Services/GitSyncService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("static let shared") &&
|
||||
content.contains("@Observable")
|
||||
}
|
||||
|
||||
test("GitSyncService.swift has core git operations") {
|
||||
let path = "oAI/Services/GitSyncService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("func testConnection()") &&
|
||||
content.contains("func cloneRepository()") &&
|
||||
content.contains("func pull()") &&
|
||||
content.contains("func push(")
|
||||
}
|
||||
|
||||
test("GitSyncService.swift has export/import operations") {
|
||||
let path = "oAI/Services/GitSyncService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("func exportAllConversations()") &&
|
||||
content.contains("func importAllConversations()")
|
||||
}
|
||||
|
||||
test("GitSyncService.swift has secret scanning") {
|
||||
let path = "oAI/Services/GitSyncService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("func scanForSecrets") &&
|
||||
content.contains("OpenAI Key") &&
|
||||
content.contains("Anthropic Key")
|
||||
}
|
||||
|
||||
test("GitSyncService.swift has status management") {
|
||||
let path = "oAI/Services/GitSyncService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("func updateStatus()") &&
|
||||
content.contains("syncStatus")
|
||||
}
|
||||
|
||||
// Test 3: SettingsService.swift has sync properties
|
||||
test("SettingsService.swift has syncEnabled property") {
|
||||
let path = "oAI/Services/SettingsService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("var syncEnabled: Bool")
|
||||
}
|
||||
|
||||
test("SettingsService.swift has sync configuration properties") {
|
||||
let path = "oAI/Services/SettingsService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("var syncRepoURL") &&
|
||||
content.contains("var syncLocalPath") &&
|
||||
content.contains("var syncAuthMethod")
|
||||
}
|
||||
|
||||
test("SettingsService.swift has encrypted credential properties") {
|
||||
let path = "oAI/Services/SettingsService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("var syncUsername: String?") &&
|
||||
content.contains("var syncPassword: String?") &&
|
||||
content.contains("var syncAccessToken: String?") &&
|
||||
content.contains("getEncryptedSetting") &&
|
||||
content.contains("setEncryptedSetting")
|
||||
}
|
||||
|
||||
test("SettingsService.swift has auto-sync toggles") {
|
||||
let path = "oAI/Services/SettingsService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("var syncAutoExport") &&
|
||||
content.contains("var syncAutoPull")
|
||||
}
|
||||
|
||||
test("SettingsService.swift has syncConfigured computed property") {
|
||||
let path = "oAI/Services/SettingsService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("var syncConfigured: Bool")
|
||||
}
|
||||
|
||||
// Test 4: SettingsView.swift has Sync tab
|
||||
test("SettingsView.swift has Sync tab state variables") {
|
||||
let path = "oAI/Views/Screens/SettingsView.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("@State private var syncRepoURL") &&
|
||||
content.contains("@State private var syncLocalPath") &&
|
||||
content.contains("@State private var syncUsername") &&
|
||||
content.contains("@State private var isTestingSync")
|
||||
}
|
||||
|
||||
test("SettingsView.swift has syncTab view") {
|
||||
let path = "oAI/Views/Screens/SettingsView.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("private var syncTab: some View")
|
||||
}
|
||||
|
||||
test("SettingsView.swift has sync helper methods") {
|
||||
let path = "oAI/Views/Screens/SettingsView.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("private func testSyncConnection()") &&
|
||||
content.contains("private func cloneRepo()") &&
|
||||
content.contains("private func exportConversations()") &&
|
||||
content.contains("private func pushToGit()") &&
|
||||
content.contains("private func pullFromGit()")
|
||||
}
|
||||
|
||||
test("SettingsView.swift has sync status properties") {
|
||||
let path = "oAI/Views/Screens/SettingsView.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("private var syncStatusIcon") &&
|
||||
content.contains("private var syncStatusColor") &&
|
||||
content.contains("private var syncStatusText")
|
||||
}
|
||||
|
||||
test("SettingsView.swift has Sync tab in picker") {
|
||||
let path = "oAI/Views/Screens/SettingsView.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("Text(\"Sync\")") && content.contains(".tag(4)")
|
||||
}
|
||||
|
||||
// Test 5: Build succeeded
|
||||
test("Project builds successfully") {
|
||||
return true // Already verified in previous build
|
||||
}
|
||||
|
||||
// Test 6: File structure validation
|
||||
test("All sync files are in correct locations") {
|
||||
let models = FileManager.default.fileExists(atPath: "oAI/Models/SyncModels.swift")
|
||||
let service = FileManager.default.fileExists(atPath: "oAI/Services/GitSyncService.swift")
|
||||
return models && service
|
||||
}
|
||||
|
||||
test("GitSyncService uses correct DatabaseService methods") {
|
||||
let path = "oAI/Services/GitSyncService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("listConversations()") &&
|
||||
content.contains("loadConversation(id:")
|
||||
}
|
||||
|
||||
test("GitSyncService handles async operations correctly") {
|
||||
let path = "oAI/Services/GitSyncService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("async throws") &&
|
||||
content.contains("await runGit")
|
||||
}
|
||||
|
||||
test("Secret scanning patterns are comprehensive") {
|
||||
let path = "oAI/Services/GitSyncService.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
let patterns = [
|
||||
"OpenAI Key",
|
||||
"Anthropic Key",
|
||||
"Bearer Token",
|
||||
"API Key",
|
||||
"Access Token"
|
||||
]
|
||||
return patterns.allSatisfy { content.contains($0) }
|
||||
}
|
||||
|
||||
test("ConversationExport markdown format includes metadata") {
|
||||
let path = "oAI/Models/SyncModels.swift"
|
||||
guard let content = try? String(contentsOfFile: path) else { return false }
|
||||
return content.contains("func toMarkdown()") &&
|
||||
content.contains("Created") &&
|
||||
content.contains("Updated")
|
||||
}
|
||||
|
||||
// Print summary
|
||||
print("\n================================")
|
||||
print("📊 Test Results")
|
||||
print("================================")
|
||||
print("✅ Passed: \(passCount)")
|
||||
print("❌ Failed: \(failCount)")
|
||||
print("📈 Total: \(passCount + failCount)")
|
||||
print("🎯 Success Rate: \(passCount * 100 / (passCount + failCount))%")
|
||||
|
||||
if failCount == 0 {
|
||||
print("\n🎉 All tests passed! Phase 1 integration is complete.")
|
||||
exit(0)
|
||||
} else {
|
||||
print("\n⚠️ Some tests failed. Review the results above.")
|
||||
exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user