iCloud Backup, better chatview exp. bugfixes++ #1
@@ -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.3;
|
||||
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.3;
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -953,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() {
|
||||
|
||||
@@ -295,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 "/"
|
||||
@@ -382,6 +385,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
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(
|
||||
@@ -1248,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 {
|
||||
@@ -1264,7 +1275,8 @@ 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
|
||||
@@ -1311,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
|
||||
|
||||
@@ -1340,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
|
||||
@@ -1362,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)")
|
||||
@@ -1462,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,
|
||||
@@ -1472,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -112,7 +112,8 @@ 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",
|
||||
@@ -191,7 +192,8 @@ private let helpCategories: [CommandCategory] = [
|
||||
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"
|
||||
),
|
||||
]),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -1823,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 {
|
||||
@@ -1856,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