From e9d0ad3c662b1caaa23873a9c8d390d1f74b0e6d Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 27 Feb 2026 14:05:11 +0100 Subject: [PATCH] iCloud Backup, better chatview exp. bugfixes++ --- oAI.xcodeproj/project.pbxproj | 4 +- oAI/Models/Message.swift | 11 ++ oAI/Providers/AnthropicProvider.swift | 43 +++++- oAI/Services/BackupService.swift | 205 +++++++++++++++++++++++++ oAI/Services/SettingsService.swift | 9 ++ oAI/ViewModels/ChatViewModel.swift | 40 ++++- oAI/Views/Main/FooterView.swift | 2 +- oAI/Views/Main/MessageRow.swift | 131 ++++++++++++++-- oAI/Views/Screens/HelpView.swift | 6 +- oAI/Views/Screens/SettingsView.swift | 206 +++++++++++++++++++++++++- oAI/oAIApp.swift | 7 +- 11 files changed, 634 insertions(+), 30 deletions(-) create mode 100644 oAI/Services/BackupService.swift diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 1a8e4bb..b64b9e8 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -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; diff --git a/oAI/Models/Message.swift b/oAI/Models/Message.swift index 4c14c05..042d2fd 100644 --- a/oAI/Models/Message.swift +++ b/oAI/Models/Message.swift @@ -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, diff --git a/oAI/Providers/AnthropicProvider.swift b/oAI/Providers/AnthropicProvider.swift index e982526..8e17e8f 100644 --- a/oAI/Providers/AnthropicProvider.swift +++ b/oAI/Providers/AnthropicProvider.swift @@ -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:;base64, + 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... + +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 = [ + "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." + } + } +} diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index e95deee..d702319 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -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() { diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 7441563..398a25f 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -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 diff --git a/oAI/Views/Main/FooterView.swift b/oAI/Views/Main/FooterView.swift index 32d6bb1..03a6192 100644 --- a/oAI/Views/Main/FooterView.swift +++ b/oAI/Views/Main/FooterView.swift @@ -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 diff --git a/oAI/Views/Main/MessageRow.swift b/oAI/Views/Main/MessageRow.swift index da50d9d..979da58 100644 --- a/oAI/Views/Main/MessageRow.swift +++ b/oAI/Views/Main/MessageRow.swift @@ -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 diff --git a/oAI/Views/Screens/HelpView.swift b/oAI/Views/Screens/HelpView.swift index eece51a..e0b8304 100644 --- a/oAI/Views/Screens/HelpView.swift +++ b/oAI/Views/Screens/HelpView.swift @@ -112,7 +112,8 @@ private let helpCategories: [CommandCategory] = [ command: "/save ", 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" ), ]), ] diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 7f299f7..162d97d 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -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" } } diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index ee34216..64dac40 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -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) {