Merge pull request 'iCloud Backup, better chatview exp. bugfixes++' (#1) from 2.3.5 into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -279,7 +279,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.3.3;
|
MARKETING_VERSION = 2.3.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -323,7 +323,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.3.3;
|
MARKETING_VERSION = 2.3.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ enum MessageRole: String, Codable {
|
|||||||
case system
|
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 {
|
struct Message: Identifiable, Codable, Equatable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let role: MessageRole
|
let role: MessageRole
|
||||||
@@ -52,6 +60,9 @@ struct Message: Identifiable, Codable, Equatable {
|
|||||||
// Generated images from image-output models (base64-decoded PNG/JPEG data)
|
// Generated images from image-output models (base64-decoded PNG/JPEG data)
|
||||||
var generatedImages: [Data]? = nil
|
var generatedImages: [Data]? = nil
|
||||||
|
|
||||||
|
// Tool call details (not persisted — in-memory only for expandable display)
|
||||||
|
var toolCalls: [ToolCallDetail]? = nil
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
role: MessageRole,
|
role: MessageRole,
|
||||||
|
|||||||
@@ -318,7 +318,41 @@ class AnthropicProvider: AIProvider {
|
|||||||
conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""])
|
conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""])
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if hasAttachments, let attachments = msg.attachments {
|
||||||
var contentBlocks: [[String: Any]] = []
|
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 {
|
for attachment in attachments {
|
||||||
guard let data = attachment.data else { continue }
|
guard let data = attachment.data else { continue }
|
||||||
@@ -541,7 +577,8 @@ class AnthropicProvider: AIProvider {
|
|||||||
}
|
}
|
||||||
apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks])
|
apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks])
|
||||||
} else {
|
} 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
|
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
|
// MARK: - UserDefaults Migration
|
||||||
|
|
||||||
private func migrateFromUserDefaultsIfNeeded() {
|
private func migrateFromUserDefaultsIfNeeded() {
|
||||||
|
|||||||
@@ -295,6 +295,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
func sendMessage() {
|
func sendMessage() {
|
||||||
guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
|
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)
|
let trimmedInput = inputText.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
// Handle slash escape: "//" becomes "/"
|
// Handle slash escape: "//" becomes "/"
|
||||||
@@ -382,6 +385,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
MCPService.shared.resetBashSessionApproval()
|
MCPService.shared.resetBashSessionApproval()
|
||||||
messages = loadedMessages
|
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
|
// Rebuild session stats from loaded messages
|
||||||
for msg in loadedMessages {
|
for msg in loadedMessages {
|
||||||
sessionStats.addMessage(
|
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
|
var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in
|
||||||
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
|
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
|
||||||
if hasAttachments {
|
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 ?? [] {
|
for attachment in msg.attachments ?? [] {
|
||||||
guard let data = attachment.data else { continue }
|
guard let data = attachment.data else { continue }
|
||||||
switch attachment.type {
|
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": 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 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
|
// Show what tools the model is calling
|
||||||
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
|
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
|
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
|
// Execute each tool and append results
|
||||||
var toolResultLines: [String] = []
|
var toolResultLines: [String] = []
|
||||||
for tc in toolCalls {
|
for (i, tc) in toolCalls.enumerated() {
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
break
|
break
|
||||||
@@ -1362,6 +1380,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
resultJSON = "{\"error\": \"Failed to serialize result\"}"
|
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 {
|
if usingTextCalls {
|
||||||
// Inject results as a user message for text-call models
|
// Inject results as a user message for text-call models
|
||||||
toolResultLines.append("Tool result for \(tc.functionName):\n\(resultJSON)")
|
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(
|
let message = Message(
|
||||||
role: .system,
|
role: .system,
|
||||||
content: text,
|
content: text,
|
||||||
@@ -1472,6 +1495,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
attachments: nil
|
attachments: nil
|
||||||
)
|
)
|
||||||
messages.append(message)
|
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
|
// MARK: - Error Helpers
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ struct FooterView: View {
|
|||||||
|
|
||||||
// Shortcuts hint
|
// Shortcuts hint
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Text("⌘N New • ⌘M Model • ⌘⇧S Save")
|
Text("⌘N New • ⌘M Model • ⌘S Save")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.oaiSecondary)
|
.foregroundColor(.oaiSecondary)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ struct MessageRow: View {
|
|||||||
let viewModel: ChatViewModel?
|
let viewModel: ChatViewModel?
|
||||||
private let settings = SettingsService.shared
|
private let settings = SettingsService.shared
|
||||||
|
|
||||||
|
@State private var isExpanded = false
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@State private var isHovering = false
|
@State private var isHovering = false
|
||||||
@State private var showCopied = false
|
@State private var showCopied = false
|
||||||
@@ -82,8 +84,8 @@ struct MessageRow: View {
|
|||||||
.help("Star this message to always include it in context")
|
.help("Star this message to always include it in context")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy button (assistant messages only, visible on hover)
|
// Copy button (user + assistant messages, visible on hover)
|
||||||
if message.role == .assistant && isHovering && !message.content.isEmpty {
|
if (message.role == .assistant || message.role == .user) && isHovering && !message.content.isEmpty {
|
||||||
Button(action: copyContent) {
|
Button(action: copyContent) {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
|
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
|
||||||
@@ -188,27 +190,126 @@ struct MessageRow: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var compactSystemMessage: some View {
|
private var compactSystemMessage: some View {
|
||||||
HStack(spacing: 8) {
|
let expandable = message.toolCalls != nil
|
||||||
Image(systemName: "wrench.and.screwdriver")
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
.font(.system(size: 11))
|
Button(action: {
|
||||||
.foregroundColor(.secondary)
|
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)
|
Text(message.content)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text(message.timestamp, style: .time)
|
if expandable {
|
||||||
.font(.system(size: 10))
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
.foregroundColor(.secondary.opacity(0.7))
|
.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))
|
.background(Color.secondary.opacity(0.08))
|
||||||
.cornerRadius(6)
|
.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
|
// MARK: - Message Content
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ private let helpCategories: [CommandCategory] = [
|
|||||||
command: "/save <name>",
|
command: "/save <name>",
|
||||||
brief: "Save current conversation",
|
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.",
|
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(
|
CommandDetail(
|
||||||
command: "/load",
|
command: "/load",
|
||||||
@@ -191,7 +192,8 @@ private let helpCategories: [CommandCategory] = [
|
|||||||
command: "/stats",
|
command: "/stats",
|
||||||
brief: "Show session statistics",
|
brief: "Show session statistics",
|
||||||
detail: "Displays statistics for the current session: message count, total tokens used, estimated cost, and session duration.",
|
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 isTestingPaperless = false
|
||||||
@State private var paperlessTestResult: String?
|
@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
|
// Email handler state
|
||||||
@State private var showEmailLog = false
|
@State private var showEmailLog = false
|
||||||
@State private var showEmailModelSelector = 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(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
|
||||||
tabButton(5, icon: "envelope", label: "Email")
|
tabButton(5, icon: "envelope", label: "Email")
|
||||||
tabButton(8, icon: "doc.text", label: "Paperless")
|
tabButton(8, icon: "doc.text", label: "Paperless")
|
||||||
|
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 12)
|
.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
|
agentSkillsTab
|
||||||
case 8:
|
case 8:
|
||||||
paperlessTab
|
paperlessTab
|
||||||
|
case 9:
|
||||||
|
backupTab
|
||||||
default:
|
default:
|
||||||
generalTab
|
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) {
|
.sheet(isPresented: $showEmailLog) {
|
||||||
EmailLogView()
|
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
|
// 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
|
// MARK: - Tab Navigation
|
||||||
|
|
||||||
private func tabButton(_ tag: Int, icon: String, label: String) -> some View {
|
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 6: return "Shortcuts"
|
||||||
case 7: return "Skills"
|
case 7: return "Skills"
|
||||||
case 8: return "Paperless"
|
case 8: return "Paperless"
|
||||||
|
case 9: return "Backup"
|
||||||
default: return "Settings"
|
default: return "Settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ struct oAIApp: App {
|
|||||||
CommandGroup(replacing: .newItem) {
|
CommandGroup(replacing: .newItem) {
|
||||||
Button("New Chat") { chatViewModel.newConversation() }
|
Button("New Chat") { chatViewModel.newConversation() }
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
|
Button("Clear Chat") { chatViewModel.clearChat() }
|
||||||
|
.keyboardShortcut("k", modifiers: .command)
|
||||||
|
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandGroup(after: .newItem) {
|
CommandGroup(after: .newItem) {
|
||||||
@@ -93,8 +96,10 @@ struct oAIApp: App {
|
|||||||
|
|
||||||
CommandGroup(replacing: .saveItem) {
|
CommandGroup(replacing: .saveItem) {
|
||||||
Button("Save Chat") { chatViewModel.saveFromMenu() }
|
Button("Save Chat") { chatViewModel.saveFromMenu() }
|
||||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
.keyboardShortcut("s", modifiers: .command)
|
||||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||||
|
Button("Stats") { chatViewModel.showStats = true }
|
||||||
|
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandGroup(after: .importExport) {
|
CommandGroup(after: .importExport) {
|
||||||
|
|||||||
Reference in New Issue
Block a user