6 Commits

Author SHA1 Message Date
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
5f9631077b Updated README.md 2026-02-20 10:04:26 +01:00
4acf538d8f Added install instructions, and version check 2026-02-20 09:55:30 +01:00
979747c1ea Removed test file 2026-02-19 16:54:45 +01:00
14 changed files with 2105 additions and 1492 deletions

1
.gitignore vendored
View File

@@ -119,3 +119,4 @@ GIT_SYNC_PHASE1_COMPLETE.md
build-dmg.sh
.claude/
*.sh
RELEASE_NOTES.md

View File

@@ -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

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.2-bugfix";
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.2-bugfix";
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;

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)
)
}

View File

@@ -110,10 +110,11 @@ class MCPService {
var respectGitignore: Bool { settings.mcpRespectGitignore }
private let anytypeService = AnytypeMCPService.shared
private let paperlessService = PaperlessService.shared
// MARK: - Tool Schema Generation
func getToolSchemas() -> [Tool] {
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
var tools: [Tool] = [
makeTool(
name: "read_file",
@@ -214,6 +215,24 @@ 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 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 +346,27 @@ class MCPService {
}
return copyFile(source: source, destination: destination)
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)"]
}
}

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,49 @@ class SettingsService {
return !key.isEmpty
}
// 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 {

View 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
}
}

View File

@@ -164,6 +164,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
@@ -435,6 +442,16 @@ 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
}
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 }
@@ -1220,7 +1237,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 +1277,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

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

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -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 {

View File

@@ -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)
}