Compare commits
5 Commits
v2.3.2-bug
...
2.3.5
| Author | SHA1 | Date | |
|---|---|---|---|
| e9d0ad3c66 | |||
| 3997f3feee | |||
| 914d608d35 | |||
| 11017ee7fa | |||
| d386888359 |
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.2-bugfix";
|
||||
MARKETING_VERSION = 2.3.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -323,7 +323,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = "2.3.2-bugfix";
|
||||
MARKETING_VERSION = 2.3.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -31,6 +31,14 @@ enum MessageRole: String, Codable {
|
||||
case system
|
||||
}
|
||||
|
||||
/// Detail for a single tool call within a system "🔧 Calling:" message.
|
||||
/// Not persisted — in-memory only.
|
||||
struct ToolCallDetail {
|
||||
let name: String
|
||||
let input: String // raw JSON string of arguments
|
||||
var result: String? // raw JSON string of result (nil while pending)
|
||||
}
|
||||
|
||||
struct Message: Identifiable, Codable, Equatable {
|
||||
let id: UUID
|
||||
let role: MessageRole
|
||||
@@ -52,6 +60,9 @@ struct Message: Identifiable, Codable, Equatable {
|
||||
// Generated images from image-output models (base64-decoded PNG/JPEG data)
|
||||
var generatedImages: [Data]? = nil
|
||||
|
||||
// Tool call details (not persisted — in-memory only for expandable display)
|
||||
var toolCalls: [ToolCallDetail]? = nil
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
role: MessageRole,
|
||||
|
||||
@@ -318,7 +318,41 @@ class AnthropicProvider: AIProvider {
|
||||
conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""])
|
||||
}
|
||||
} else {
|
||||
conversationMessages.append(["role": role, "content": msg["content"] as? String ?? ""])
|
||||
// Content may be a String (plain text) or an Array (image/multipart blocks)
|
||||
if let contentArray = msg["content"] as? [[String: Any]] {
|
||||
// Convert image_url blocks (OpenAI format) to Anthropic image blocks
|
||||
let anthropicBlocks: [[String: Any]] = contentArray.compactMap { block in
|
||||
let type = block["type"] as? String ?? ""
|
||||
if type == "text" {
|
||||
return block
|
||||
} else if type == "image_url",
|
||||
let imageUrl = block["image_url"] as? [String: Any],
|
||||
let url = imageUrl["url"] as? String,
|
||||
url.hasPrefix("data:") {
|
||||
// data:<mediaType>;base64,<data>
|
||||
let withoutData = url.dropFirst(5) // strip "data:"
|
||||
guard let semicolon = withoutData.firstIndex(of: ";"),
|
||||
let comma = withoutData.firstIndex(of: ",") else { return nil }
|
||||
let mediaType = String(withoutData[withoutData.startIndex..<semicolon])
|
||||
let base64Data = String(withoutData[withoutData.index(after: comma)...])
|
||||
return [
|
||||
"type": "image",
|
||||
"source": [
|
||||
"type": "base64",
|
||||
"media_type": mediaType,
|
||||
"data": base64Data
|
||||
]
|
||||
]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !anthropicBlocks.isEmpty {
|
||||
conversationMessages.append(["role": role, "content": anthropicBlocks])
|
||||
}
|
||||
} else {
|
||||
let content = (msg["content"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
conversationMessages.append(["role": role, "content": content.isEmpty ? "[Image]" : content])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +552,9 @@ class AnthropicProvider: AIProvider {
|
||||
|
||||
if hasAttachments, let attachments = msg.attachments {
|
||||
var contentBlocks: [[String: Any]] = []
|
||||
contentBlocks.append(["type": "text", "text": msg.content])
|
||||
if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
contentBlocks.append(["type": "text", "text": msg.content])
|
||||
}
|
||||
|
||||
for attachment in attachments {
|
||||
guard let data = attachment.data else { continue }
|
||||
@@ -541,7 +577,8 @@ class AnthropicProvider: AIProvider {
|
||||
}
|
||||
apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks])
|
||||
} else {
|
||||
apiMessages.append(["role": msg.role.rawValue, "content": msg.content])
|
||||
let content = msg.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
apiMessages.append(["role": msg.role.rawValue, "content": content.isEmpty ? "[Image]" : content])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
205
oAI/Services/BackupService.swift
Normal file
205
oAI/Services/BackupService.swift
Normal file
@@ -0,0 +1,205 @@
|
||||
//
|
||||
// BackupService.swift
|
||||
// oAI
|
||||
//
|
||||
// iCloud Drive backup of non-encrypted settings (Option C, v1)
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of oAI.
|
||||
//
|
||||
// oAI is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
||||
// Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public
|
||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - BackupManifest
|
||||
|
||||
struct BackupManifest: Codable {
|
||||
let version: Int
|
||||
let createdAt: String
|
||||
let appVersion: String
|
||||
let credentialsIncluded: Bool
|
||||
let settings: [String: String]
|
||||
let credentials: [String: String]?
|
||||
}
|
||||
|
||||
// MARK: - BackupService
|
||||
|
||||
@Observable
|
||||
final class BackupService {
|
||||
static let shared = BackupService()
|
||||
|
||||
private let log = Logger(subsystem: "oAI", category: "backup")
|
||||
|
||||
/// Whether iCloud Drive is available on this machine
|
||||
var iCloudAvailable: Bool = false
|
||||
|
||||
/// Date of the last backup file on disk (from file attributes)
|
||||
var lastBackupDate: Date?
|
||||
|
||||
/// URL of the last backup file
|
||||
var lastBackupURL: URL?
|
||||
|
||||
// Keys excluded from backup — encrypted_ prefix + internal migration flags
|
||||
private static let excludedKeys: Set<String> = [
|
||||
"encrypted_openrouterAPIKey",
|
||||
"encrypted_anthropicAPIKey",
|
||||
"encrypted_openaiAPIKey",
|
||||
"encrypted_googleAPIKey",
|
||||
"encrypted_googleSearchEngineID",
|
||||
"encrypted_anytypeMcpAPIKey",
|
||||
"encrypted_paperlessAPIToken",
|
||||
"encrypted_syncUsername",
|
||||
"encrypted_syncPassword",
|
||||
"encrypted_syncAccessToken",
|
||||
"encrypted_emailUsername",
|
||||
"encrypted_emailPassword",
|
||||
"_migrated",
|
||||
"_keychain_migrated",
|
||||
]
|
||||
|
||||
private init() {
|
||||
checkForExistingBackup()
|
||||
}
|
||||
|
||||
// MARK: - iCloud Path Resolution
|
||||
|
||||
private func resolveBackupDirectory() -> URL {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
let icloudRoot = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs")
|
||||
if FileManager.default.fileExists(atPath: icloudRoot.path) {
|
||||
let icloudOAI = icloudRoot.appendingPathComponent("oAI")
|
||||
try? FileManager.default.createDirectory(at: icloudOAI, withIntermediateDirectories: true)
|
||||
return icloudOAI
|
||||
}
|
||||
// Fallback: Downloads
|
||||
return FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
}
|
||||
|
||||
func checkForExistingBackup() {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
let icloudRoot = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs")
|
||||
iCloudAvailable = FileManager.default.fileExists(atPath: icloudRoot.path)
|
||||
|
||||
let dir = resolveBackupDirectory()
|
||||
let fileURL = dir.appendingPathComponent("oai_backup.json")
|
||||
if FileManager.default.fileExists(atPath: fileURL.path),
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
|
||||
let modified = attrs[.modificationDate] as? Date {
|
||||
lastBackupDate = modified
|
||||
lastBackupURL = fileURL
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export
|
||||
|
||||
/// Export all non-encrypted settings to iCloud Drive (or Downloads).
|
||||
/// Returns the URL where the file was written.
|
||||
@discardableResult
|
||||
func exportSettings() async throws -> URL {
|
||||
// Load raw settings from DB
|
||||
guard let allSettings = try? DatabaseService.shared.loadAllSettings() else {
|
||||
throw BackupError.databaseReadFailed
|
||||
}
|
||||
|
||||
// Filter out excluded keys
|
||||
let filtered = allSettings.filter { !Self.excludedKeys.contains($0.key) }
|
||||
|
||||
// Build manifest
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
let manifest = BackupManifest(
|
||||
version: 1,
|
||||
createdAt: formatter.string(from: Date()),
|
||||
appVersion: appVersion(),
|
||||
credentialsIncluded: false,
|
||||
settings: filtered,
|
||||
credentials: nil
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(manifest)
|
||||
|
||||
let dir = resolveBackupDirectory()
|
||||
let fileURL = dir.appendingPathComponent("oai_backup.json")
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
|
||||
log.info("Backup written to \(fileURL.path, privacy: .public) (\(filtered.count) settings)")
|
||||
|
||||
await MainActor.run {
|
||||
self.lastBackupDate = Date()
|
||||
self.lastBackupURL = fileURL
|
||||
}
|
||||
|
||||
return fileURL
|
||||
}
|
||||
|
||||
// MARK: - Import
|
||||
|
||||
/// Restore settings from a backup JSON file.
|
||||
func importSettings(from url: URL) async throws {
|
||||
let data = try Data(contentsOf: url)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let manifest: BackupManifest
|
||||
do {
|
||||
manifest = try decoder.decode(BackupManifest.self, from: data)
|
||||
} catch {
|
||||
throw BackupError.invalidFormat(error.localizedDescription)
|
||||
}
|
||||
|
||||
guard manifest.version == 1 else {
|
||||
throw BackupError.unsupportedVersion(manifest.version)
|
||||
}
|
||||
|
||||
// Write each setting to the database
|
||||
for (key, value) in manifest.settings {
|
||||
DatabaseService.shared.setSetting(key: key, value: value)
|
||||
}
|
||||
|
||||
// Refresh in-memory cache
|
||||
SettingsService.shared.reloadFromDatabase()
|
||||
|
||||
log.info("Restored \(manifest.settings.count) settings from backup (v\(manifest.version))")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func appVersion() -> String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BackupError
|
||||
|
||||
enum BackupError: LocalizedError {
|
||||
case databaseReadFailed
|
||||
case invalidFormat(String)
|
||||
case unsupportedVersion(Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .databaseReadFailed:
|
||||
return "Could not read settings from the database."
|
||||
case .invalidFormat(let detail):
|
||||
return "The backup file is not valid: \(detail)"
|
||||
case .unsupportedVersion(let v):
|
||||
return "Backup version \(v) is not supported by this version of oAI."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -112,6 +112,18 @@ class MCPService {
|
||||
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(onlineMode: Bool = false) -> [Tool] {
|
||||
@@ -220,6 +232,21 @@ class MCPService {
|
||||
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 {
|
||||
@@ -346,6 +373,20 @@ 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 {
|
||||
@@ -706,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
|
||||
|
||||
@@ -447,6 +447,40 @@ 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 {
|
||||
@@ -919,6 +953,15 @@ class SettingsService {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Cache Reload
|
||||
|
||||
/// Replace the in-memory cache with a fresh read from the database.
|
||||
/// Called by BackupService after restoring a backup.
|
||||
func reloadFromDatabase() {
|
||||
cache = (try? DatabaseService.shared.loadAllSettings()) ?? [:]
|
||||
Log.settings.info("Settings cache reloaded (\(self.cache.count) entries)")
|
||||
}
|
||||
|
||||
// MARK: - UserDefaults Migration
|
||||
|
||||
private func migrateFromUserDefaultsIfNeeded() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -297,6 +295,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
func sendMessage() {
|
||||
guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
|
||||
|
||||
// If already generating, cancel first — new message becomes a followup in context
|
||||
if isGenerating { cancelGeneration() }
|
||||
|
||||
let trimmedInput = inputText.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Handle slash escape: "//" becomes "/"
|
||||
@@ -354,59 +355,21 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
streamingTask?.cancel()
|
||||
streamingTask = nil
|
||||
isGenerating = false
|
||||
cancelAutoContinue() // Also cancel any pending auto-continue
|
||||
silentContinuePrompt = nil
|
||||
}
|
||||
|
||||
func startAutoContinue() {
|
||||
isAutoContinuing = true
|
||||
autoContinueCountdown = 5
|
||||
|
||||
autoContinueTask = Task { @MainActor in
|
||||
// Countdown from 5 to 1
|
||||
for i in (1...5).reversed() {
|
||||
if Task.isCancelled {
|
||||
isAutoContinuing = false
|
||||
return
|
||||
}
|
||||
autoContinueCountdown = i
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
}
|
||||
|
||||
// Continue the task
|
||||
isAutoContinuing = false
|
||||
autoContinueCountdown = 0
|
||||
|
||||
let continuePrompt = "Please continue from where you left off."
|
||||
|
||||
// Add user message
|
||||
let userMessage = Message(
|
||||
role: .user,
|
||||
content: continuePrompt,
|
||||
tokens: nil,
|
||||
cost: nil,
|
||||
timestamp: Date(),
|
||||
attachments: nil,
|
||||
responseTime: nil,
|
||||
wasInterrupted: false,
|
||||
modelId: selectedModel?.id
|
||||
)
|
||||
messages.append(userMessage)
|
||||
|
||||
// Continue generation
|
||||
generateAIResponse(to: continuePrompt, attachments: nil)
|
||||
showSystemMessage("↩ Continuing…")
|
||||
silentContinuePrompt = "Please continue from where you left off."
|
||||
Task { @MainActor in
|
||||
generateAIResponse(to: "", attachments: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelAutoContinue() {
|
||||
autoContinueTask?.cancel()
|
||||
autoContinueTask = nil
|
||||
isAutoContinuing = false
|
||||
autoContinueCountdown = 0
|
||||
}
|
||||
|
||||
func clearChat() {
|
||||
messages.removeAll()
|
||||
sessionStats.reset()
|
||||
MCPService.shared.resetBashSessionApproval()
|
||||
showSystemMessage("Chat cleared")
|
||||
}
|
||||
|
||||
@@ -419,8 +382,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
messages.removeAll()
|
||||
sessionStats.reset()
|
||||
MCPService.shared.resetBashSessionApproval()
|
||||
messages = loadedMessages
|
||||
|
||||
// Track identity so ⌘S can re-save under the same name
|
||||
currentConversationId = conversation.id
|
||||
currentConversationName = conversation.name
|
||||
savedMessageCount = loadedMessages.filter { $0.role != .system }.count
|
||||
|
||||
// Rebuild session stats from loaded messages
|
||||
for msg in loadedMessages {
|
||||
sessionStats.addMessage(
|
||||
@@ -450,6 +419,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
currentProvider = newProvider
|
||||
settings.defaultModel = model.id
|
||||
settings.defaultProvider = newProvider
|
||||
MCPService.shared.resetBashSessionApproval()
|
||||
}
|
||||
|
||||
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
||||
@@ -784,8 +754,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let mcp = MCPService.shared
|
||||
let mcpActive = mcpEnabled || settings.mcpEnabled
|
||||
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
|
||||
let bashActive = settings.bashEnabled
|
||||
let modelSupportTools = selectedModel?.capabilities.tools ?? false
|
||||
if modelSupportTools && (anytypeActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||
if modelSupportTools && (anytypeActive || bashActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||
generateAIResponseWithTools(provider: provider, modelId: modelId)
|
||||
return
|
||||
}
|
||||
@@ -898,7 +869,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
temperature: chatRequest.temperature,
|
||||
imageGeneration: true
|
||||
)
|
||||
let response = try await provider.chat(request: nonStreamRequest)
|
||||
let response = try await withOverloadedRetry { try await provider.chat(request: nonStreamRequest) }
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
@@ -1285,7 +1256,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in
|
||||
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
|
||||
if hasAttachments {
|
||||
var contentArray: [[String: Any]] = [["type": "text", "text": msg.content]]
|
||||
var contentArray: [[String: Any]] = []
|
||||
if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
contentArray.append(["type": "text", "text": msg.content])
|
||||
}
|
||||
for attachment in msg.attachments ?? [] {
|
||||
guard let data = attachment.data else { continue }
|
||||
switch attachment.type {
|
||||
@@ -1301,7 +1275,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
}
|
||||
return ["role": msg.role.rawValue, "content": contentArray]
|
||||
}
|
||||
return ["role": msg.role.rawValue, "content": msg.content]
|
||||
let content = msg.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return ["role": msg.role.rawValue, "content": content.isEmpty ? "[Image]" : content]
|
||||
}
|
||||
|
||||
// If this is a silent auto-continue, inject the prompt into the API call only
|
||||
if let continuePrompt = silentContinuePrompt {
|
||||
apiMessages.append(["role": "user", "content": continuePrompt])
|
||||
silentContinuePrompt = nil
|
||||
}
|
||||
|
||||
let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit
|
||||
@@ -1315,13 +1296,15 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
break
|
||||
}
|
||||
|
||||
let response = try await provider.chatWithToolMessages(
|
||||
model: effectiveModelId,
|
||||
messages: apiMessages,
|
||||
tools: tools,
|
||||
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
|
||||
temperature: settings.temperature > 0 ? settings.temperature : nil
|
||||
)
|
||||
let response = try await withOverloadedRetry {
|
||||
try await provider.chatWithToolMessages(
|
||||
model: effectiveModelId,
|
||||
messages: apiMessages,
|
||||
tools: tools,
|
||||
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
|
||||
temperature: settings.temperature > 0 ? settings.temperature : nil
|
||||
)
|
||||
}
|
||||
|
||||
if let usage = response.usage { totalUsage = usage }
|
||||
|
||||
@@ -1340,7 +1323,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
// Show what tools the model is calling
|
||||
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
|
||||
showSystemMessage("🔧 Calling: \(toolNames)")
|
||||
let toolMsgId = showSystemMessage("🔧 Calling: \(toolNames)")
|
||||
|
||||
// Initialise detail entries with inputs (results fill in below)
|
||||
var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
|
||||
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
|
||||
}
|
||||
updateToolCallMessage(id: toolMsgId, details: toolDetails)
|
||||
|
||||
let usingTextCalls = !textCalls.isEmpty
|
||||
|
||||
@@ -1369,7 +1358,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
// Execute each tool and append results
|
||||
var toolResultLines: [String] = []
|
||||
for tc in toolCalls {
|
||||
for (i, tc) in toolCalls.enumerated() {
|
||||
if Task.isCancelled {
|
||||
wasCancelled = true
|
||||
break
|
||||
@@ -1391,6 +1380,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
resultJSON = "{\"error\": \"Failed to serialize result\"}"
|
||||
}
|
||||
|
||||
// Update the detail entry with the result so the UI can show it
|
||||
toolDetails[i].result = resultJSON
|
||||
updateToolCallMessage(id: toolMsgId, details: toolDetails)
|
||||
|
||||
if usingTextCalls {
|
||||
// Inject results as a user message for text-call models
|
||||
toolResultLines.append("Tool result for \(tc.functionName):\n\(resultJSON)")
|
||||
@@ -1491,7 +1484,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
}
|
||||
}
|
||||
|
||||
private func showSystemMessage(_ text: String) {
|
||||
@discardableResult
|
||||
private func showSystemMessage(_ text: String) -> UUID {
|
||||
let message = Message(
|
||||
role: .system,
|
||||
content: text,
|
||||
@@ -1501,6 +1495,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
attachments: nil
|
||||
)
|
||||
messages.append(message)
|
||||
return message.id
|
||||
}
|
||||
|
||||
private func updateToolCallMessage(id: UUID, details: [ToolCallDetail]) {
|
||||
if let idx = messages.firstIndex(where: { $0.id == id }) {
|
||||
messages[idx].toolCalls = details
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Helpers
|
||||
@@ -1536,6 +1537,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
return "Server error. The provider may be experiencing issues. Try again shortly."
|
||||
}
|
||||
|
||||
// Overloaded / 529
|
||||
if desc.contains("529") || desc.lowercased().contains("overloaded") {
|
||||
return "API is overloaded. Please try again shortly."
|
||||
}
|
||||
|
||||
// Timeout patterns
|
||||
if desc.lowercased().contains("timed out") || desc.lowercased().contains("timeout") {
|
||||
return "Request timed out. Try a shorter message or different model."
|
||||
@@ -1545,6 +1551,30 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
return desc
|
||||
}
|
||||
|
||||
/// Retry an async operation on overloaded (529) errors with exponential backoff.
|
||||
private func withOverloadedRetry<T>(maxAttempts: Int = 4, operation: () async throws -> T) async throws -> T {
|
||||
var attempt = 0
|
||||
while true {
|
||||
do {
|
||||
return try await operation()
|
||||
} catch {
|
||||
let desc = error.localizedDescription
|
||||
let isOverloaded = desc.contains("529") || desc.lowercased().contains("overloaded")
|
||||
attempt += 1
|
||||
if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
|
||||
let delay = Double(1 << attempt) // 2s, 4s, 8s
|
||||
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
|
||||
await MainActor.run {
|
||||
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
|
||||
}
|
||||
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func showModelInfo(_ model: ModelInfo) {
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ struct FooterView: View {
|
||||
|
||||
// Shortcuts hint
|
||||
#if os(macOS)
|
||||
Text("⌘N New • ⌘M Model • ⌘⇧S Save")
|
||||
Text("⌘N New • ⌘M Model • ⌘S Save")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
#endif
|
||||
|
||||
@@ -33,6 +33,8 @@ struct MessageRow: View {
|
||||
let viewModel: ChatViewModel?
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
@State private var isExpanded = false
|
||||
|
||||
#if os(macOS)
|
||||
@State private var isHovering = false
|
||||
@State private var showCopied = false
|
||||
@@ -82,8 +84,8 @@ struct MessageRow: View {
|
||||
.help("Star this message to always include it in context")
|
||||
}
|
||||
|
||||
// Copy button (assistant messages only, visible on hover)
|
||||
if message.role == .assistant && isHovering && !message.content.isEmpty {
|
||||
// Copy button (user + assistant messages, visible on hover)
|
||||
if (message.role == .assistant || message.role == .user) && isHovering && !message.content.isEmpty {
|
||||
Button(action: copyContent) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
|
||||
@@ -188,27 +190,126 @@ struct MessageRow: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var compactSystemMessage: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
let expandable = message.toolCalls != nil
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button(action: {
|
||||
if expandable {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(message.content)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
Text(message.content)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
Text(message.timestamp, style: .time)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
if expandable {
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
}
|
||||
|
||||
Text(message.timestamp, style: .time)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded, let calls = message.toolCalls {
|
||||
Divider()
|
||||
.padding(.horizontal, 8)
|
||||
toolCallsDetailView(calls)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func toolCallsDetailView(_ calls: [ToolCallDetail]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(calls.indices, id: \.self) { i in
|
||||
let call = calls[i]
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Tool name + status
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "function")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
Text(call.name)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
if call.result == nil {
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
.frame(width: 12, height: 12)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.green.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
// Input
|
||||
if !call.input.isEmpty && call.input != "{}" {
|
||||
toolDetailSection(label: "Input", text: prettyJSON(call.input), maxHeight: 100)
|
||||
}
|
||||
|
||||
// Result
|
||||
if let result = call.result {
|
||||
toolDetailSection(label: "Result", text: prettyJSON(result), maxHeight: 180)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if i < calls.count - 1 {
|
||||
Divider().padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func toolDetailSection(label: String, text: String, maxHeight: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundColor(.secondary.opacity(0.6))
|
||||
ScrollView([.vertical, .horizontal]) {
|
||||
Text(text)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxHeight: maxHeight)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
|
||||
private func prettyJSON(_ raw: String) -> String {
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
|
||||
let str = String(data: pretty, encoding: .utf8) else {
|
||||
return raw
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
// MARK: - Message Content
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
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]",
|
||||
@@ -108,19 +112,22 @@ private let helpCategories: [CommandCategory] = [
|
||||
command: "/save <name>",
|
||||
brief: "Save current conversation",
|
||||
detail: "Saves all messages in the current session under the given name. You can load it later to continue the conversation.",
|
||||
examples: ["/save my-project-chat", "/save debug session"]
|
||||
examples: ["/save my-project-chat", "/save debug session"],
|
||||
shortcut: "⌘S"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/load",
|
||||
brief: "Load saved conversation",
|
||||
detail: "Opens the conversation list to browse and load a previously saved conversation. Replaces the current chat.",
|
||||
examples: ["/load"]
|
||||
examples: ["/load"],
|
||||
shortcut: "⌘L"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/list",
|
||||
brief: "List saved conversations",
|
||||
detail: "Opens the conversation list showing all saved conversations with their dates and message counts.",
|
||||
examples: ["/list"]
|
||||
examples: ["/list"],
|
||||
shortcut: "⌘L"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/delete <name>",
|
||||
@@ -178,13 +185,15 @@ private let helpCategories: [CommandCategory] = [
|
||||
command: "/config",
|
||||
brief: "Open settings",
|
||||
detail: "Opens the settings panel where you can configure providers, API keys, MCP permissions, appearance, and more. Also available via /settings.",
|
||||
examples: ["/config", "/settings"]
|
||||
examples: ["/config", "/settings"],
|
||||
shortcut: "⌘,"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/stats",
|
||||
brief: "Show session statistics",
|
||||
detail: "Displays statistics for the current session: message count, total tokens used, estimated cost, and session duration.",
|
||||
examples: ["/stats"]
|
||||
examples: ["/stats"],
|
||||
shortcut: "⌘⇧S"
|
||||
),
|
||||
]),
|
||||
]
|
||||
@@ -461,6 +470,15 @@ private struct CommandRow: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if let shortcut = command.shortcut {
|
||||
Text(shortcut)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
@@ -65,6 +65,14 @@ struct SettingsView: View {
|
||||
@State private var isTestingPaperless = false
|
||||
@State private var paperlessTestResult: String?
|
||||
|
||||
// Backup state
|
||||
private let backupService = BackupService.shared
|
||||
@State private var isExporting = false
|
||||
@State private var isImporting = false
|
||||
@State private var backupMessage: String?
|
||||
@State private var backupMessageIsError = false
|
||||
@State private var showRestoreFilePicker = false
|
||||
|
||||
// Email handler state
|
||||
@State private var showEmailLog = false
|
||||
@State private var showEmailModelSelector = false
|
||||
@@ -135,6 +143,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
|
||||
tabButton(5, icon: "envelope", label: "Email")
|
||||
tabButton(8, icon: "doc.text", label: "Paperless")
|
||||
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
@@ -162,6 +171,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
agentSkillsTab
|
||||
case 8:
|
||||
paperlessTab
|
||||
case 9:
|
||||
backupTab
|
||||
default:
|
||||
generalTab
|
||||
}
|
||||
@@ -171,10 +182,24 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
|
||||
}
|
||||
.frame(minWidth: 740, idealWidth: 820, minHeight: 620, idealHeight: 760)
|
||||
.frame(minWidth: 860, idealWidth: 940, minHeight: 620, idealHeight: 760)
|
||||
.sheet(isPresented: $showEmailLog) {
|
||||
EmailLogView()
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showRestoreFilePicker,
|
||||
allowedContentTypes: [.json],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else { return }
|
||||
Task { await performRestore(from: url) }
|
||||
case .failure(let error):
|
||||
backupMessage = "Could not open file: \(error.localizedDescription)"
|
||||
backupMessageIsError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - General Tab
|
||||
@@ -528,6 +553,99 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
|
||||
// Anytype integration UI hidden (work in progress — see AnytypeMCPService.swift)
|
||||
|
||||
// MARK: Bash Execution
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "terminal.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.orange)
|
||||
Text("Bash Execution")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
}
|
||||
Text("Allow the AI to run shell commands on your machine. Commands are executed via /bin/zsh. Enable approval mode to review each command before it runs.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Status")
|
||||
formSection {
|
||||
row("Enable Bash Execution") {
|
||||
Toggle("", isOn: $settingsService.bashEnabled)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: settingsService.bashEnabled ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(settingsService.bashEnabled ? .orange : .secondary)
|
||||
.font(.system(size: 13))
|
||||
Text(settingsService.bashEnabled ? "Active — AI can run shell commands" : "Disabled")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
if settingsService.bashEnabled {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Settings")
|
||||
formSection {
|
||||
row("Require Approval") {
|
||||
Toggle("", isOn: $settingsService.bashRequireApproval)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
rowDivider()
|
||||
row("Working Directory") {
|
||||
TextField("~", text: $settingsService.bashWorkingDirectory)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 200)
|
||||
}
|
||||
rowDivider()
|
||||
row("Timeout (seconds)") {
|
||||
HStack(spacing: 8) {
|
||||
Stepper("", value: $settingsService.bashTimeout, in: 5...300, step: 5)
|
||||
.labelsHidden()
|
||||
Text("\(settingsService.bashTimeout)s")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 36, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if settingsService.bashRequireApproval {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "hand.raised.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Each command will require your approval before running.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.red)
|
||||
.padding(.top, 1)
|
||||
Text("Auto-execute mode: commands run without approval. Use with caution.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Appearance Tab
|
||||
@@ -1730,6 +1848,184 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backup Tab
|
||||
|
||||
@ViewBuilder
|
||||
private var backupTab: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Warning notice
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("iCloud Drive Backup")
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.system(size: 14))
|
||||
Text("API keys and credentials are **not** included in the backup. You will need to re-enter them after restoring on a new machine.")
|
||||
.font(.system(size: 13))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
// Status
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Status")
|
||||
formSection {
|
||||
row("iCloud Drive") {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(backupService.iCloudAvailable ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(backupService.iCloudAvailable ? "Available" : "Not available — using Downloads")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
rowDivider()
|
||||
row("Last Backup") {
|
||||
if let date = backupService.lastBackupDate {
|
||||
Text(formatBackupDate(date))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Never")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Actions")
|
||||
formSection {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: { Task { await performBackup() } }) {
|
||||
HStack(spacing: 6) {
|
||||
if isExporting {
|
||||
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
|
||||
} else {
|
||||
Image(systemName: "icloud.and.arrow.up")
|
||||
}
|
||||
Text("Back Up Now")
|
||||
}
|
||||
}
|
||||
.disabled(isExporting || isImporting)
|
||||
|
||||
Button(action: { showRestoreFilePicker = true }) {
|
||||
HStack(spacing: 6) {
|
||||
if isImporting {
|
||||
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
|
||||
} else {
|
||||
Image(systemName: "icloud.and.arrow.down")
|
||||
}
|
||||
Text("Restore from File…")
|
||||
}
|
||||
}
|
||||
.disabled(isExporting || isImporting)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
if let msg = backupMessage {
|
||||
rowDivider()
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: backupMessageIsError ? "xmark.circle.fill" : "checkmark.circle.fill")
|
||||
.foregroundStyle(backupMessageIsError ? .red : .green)
|
||||
Text(msg)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(backupMessageIsError ? .red : .primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backup location
|
||||
if let url = backupService.lastBackupURL {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Backup location:")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
Text(url.path.replacingOccurrences(
|
||||
of: FileManager.default.homeDirectoryForCurrentUser.path,
|
||||
with: "~"))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
backupService.checkForExistingBackup()
|
||||
}
|
||||
}
|
||||
|
||||
private func performBackup() async {
|
||||
await MainActor.run {
|
||||
isExporting = true
|
||||
backupMessage = nil
|
||||
}
|
||||
do {
|
||||
let url = try await backupService.exportSettings()
|
||||
let shortPath = url.path.replacingOccurrences(
|
||||
of: FileManager.default.homeDirectoryForCurrentUser.path,
|
||||
with: "~")
|
||||
await MainActor.run {
|
||||
backupMessage = "Backup saved to \(shortPath)"
|
||||
backupMessageIsError = false
|
||||
isExporting = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
backupMessage = error.localizedDescription
|
||||
backupMessageIsError = true
|
||||
isExporting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performRestore(from url: URL) async {
|
||||
await MainActor.run {
|
||||
isImporting = true
|
||||
backupMessage = nil
|
||||
}
|
||||
do {
|
||||
_ = url.startAccessingSecurityScopedResource()
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
try await backupService.importSettings(from: url)
|
||||
await MainActor.run {
|
||||
backupMessage = "Settings restored. Re-enter your API keys to resume using oAI."
|
||||
backupMessageIsError = false
|
||||
isImporting = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
backupMessage = error.localizedDescription
|
||||
backupMessageIsError = true
|
||||
isImporting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatBackupDate(_ date: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
if cal.isDateInToday(date) {
|
||||
let tf = DateFormatter()
|
||||
tf.dateFormat = "HH:mm"
|
||||
return "Today \(tf.string(from: date))"
|
||||
}
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "dd.MM.yyyy HH:mm"
|
||||
return df.string(from: date)
|
||||
}
|
||||
|
||||
// MARK: - Tab Navigation
|
||||
|
||||
private func tabButton(_ tag: Int, icon: String, label: String) -> some View {
|
||||
@@ -1763,6 +2059,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
case 6: return "Shortcuts"
|
||||
case 7: return "Skills"
|
||||
case 8: return "Paperless"
|
||||
case 9: return "Backup"
|
||||
default: return "Settings"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ struct oAIApp: App {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Chat") { chatViewModel.newConversation() }
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
Button("Clear Chat") { chatViewModel.clearChat() }
|
||||
.keyboardShortcut("k", modifiers: .command)
|
||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||
}
|
||||
|
||||
CommandGroup(after: .newItem) {
|
||||
@@ -93,8 +96,10 @@ struct oAIApp: App {
|
||||
|
||||
CommandGroup(replacing: .saveItem) {
|
||||
Button("Save Chat") { chatViewModel.saveFromMenu() }
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||
Button("Stats") { chatViewModel.showStats = true }
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||
}
|
||||
|
||||
CommandGroup(after: .importExport) {
|
||||
|
||||
Reference in New Issue
Block a user