From 8451db11421d166b5df6f423f9fbc3f5f815391d Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 16 Jun 2026 11:18:48 +0200 Subject: [PATCH 01/14] UI redesign Phase 1: NavigationSplitView with collapsible sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace root VStack with NavigationSplitView (2-column, collapsible sidebar) - Add SidebarView: new chat button, conversation search, list with swipe actions - Slim HeaderView to text-only (provider + model + star); remove all icon rows - Move status pills (Online, MCP, Synced) to footer right side - Remove version number and shortcut hints from footer - Add resizable InputBar with drag handle (persisted height) and globe/network.slash online toggle - Fix Norwegian menu appearing on English systems (CFBundleLocalizations in Info.plist) - Add View menu (Model Info, History, Stats, Credits, Online Mode toggle ⌘⇧O) - Add ⌘L as alias for Search Conversations (muscle memory for /load users) - Add Check for Updates to Help menu with download URL from Gitea API - Add one-time Intel/Rosetta deprecation warning on first launch - Swift 6: fix self.Self.isoString() call sites in DatabaseService Co-Authored-By: Claude Sonnet 4.6 --- oAI.xcodeproj/project.pbxproj | 12 +- .../xcshareddata/xcschemes/oAI.xcscheme | 2 +- oAI/Info.plist | 8 + oAI/Localizable.xcstrings | 128 +++++++++++ oAI/Services/DatabaseService.swift | 68 +++--- oAI/Services/EmbeddingService.swift | 4 +- oAI/Services/EncryptionService.swift | 6 +- oAI/Services/GitSyncService.swift | 2 +- oAI/Services/SettingsService.swift | 18 ++ oAI/Services/UpdateCheckService.swift | 30 +++ oAI/Utilities/Logging.swift | 38 +-- oAI/ViewModels/ChatViewModel.swift | 4 +- oAI/Views/Main/ChatView.swift | 15 +- oAI/Views/Main/ContentView.swift | 176 +++++--------- oAI/Views/Main/FooterView.swift | 37 +-- oAI/Views/Main/HeaderView.swift | 73 +----- oAI/Views/Main/InputBar.swift | 192 ++++++++-------- oAI/Views/Main/SidebarView.swift | 216 ++++++++++++++++++ oAI/oAIApp.swift | 36 +++ 19 files changed, 702 insertions(+), 363 deletions(-) create mode 100644 oAI/Views/Main/SidebarView.swift diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 11fd0a9..a2ee833 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -279,11 +279,11 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; 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.9; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -323,11 +323,11 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; 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.9; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme b/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme index 1ace073..2336f58 100644 --- a/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme +++ b/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme @@ -34,7 +34,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - language = "nb" + language = "en" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/oAI/Info.plist b/oAI/Info.plist index eee344e..5d5967f 100644 --- a/oAI/Info.plist +++ b/oAI/Info.plist @@ -6,5 +6,13 @@ oAI.help CFBundleHelpBookName oAI Help + CFBundleLocalizations + + en + nb + da + de + sv + diff --git a/oAI/Localizable.xcstrings b/oAI/Localizable.xcstrings index b7fd5c3..46e7901 100644 --- a/oAI/Localizable.xcstrings +++ b/oAI/Localizable.xcstrings @@ -1,6 +1,13 @@ { "sourceLanguage" : "en", "strings" : { + "·" : { + "comment" : "A separator between the message count and the date.", + "isCommentAutoGenerated" : true + }, + "· %@" : { + + }, "(always used)" : { "localizations" : { "da" : { @@ -432,6 +439,12 @@ } } } + }, + "^[%@ message](inflect: true)" : { + + }, + "^[%@ token](inflect: true)" : { + }, "© 2026 [Rune Olsen](https://blog.rune.pm)" : { "comment" : "A copyright notice with the copyright holder's name.", @@ -521,6 +534,7 @@ }, "⌘N New • ⌘M Model • ⌘S Save" : { "comment" : "A hint that appears on macOS when using keyboard shortcuts.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "da" : { @@ -549,6 +563,10 @@ } } }, + "⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly." : { + "comment" : "A warning displayed in the settings view.", + "isCommentAutoGenerated" : true + }, "⚠️ Custom prompt active — only this prompt will be sent to the model." : { "localizations" : { "da" : { @@ -894,6 +912,12 @@ } } } + }, + "🧠" : { + + }, + "1. Open Anytype → Settings → Integrations" : { + }, "1. Open Paperless-NGX → Settings → API Tokens" : { "comment" : "A step in the process of getting a Paperless-NGX API token.", @@ -925,6 +949,10 @@ } } }, + "2. Create a new API key" : { + "comment" : "A step in the process of getting an API key from Anytype.", + "isCommentAutoGenerated" : true + }, "2. Create or copy your token" : { "comment" : "A step in the process of getting a Paperless-NGX API token.", "isCommentAutoGenerated" : true, @@ -1132,6 +1160,9 @@ } } } + }, + "Agent" : { + }, "Agent Skills" : { "localizations" : { @@ -1160,6 +1191,9 @@ } } } + }, + "Agents" : { + }, "Allow Shell Command?" : { "comment" : "A title for a modal that asks the user if they want to allow a shell command.", @@ -1566,6 +1600,9 @@ } } } + }, + "Category" : { + }, "Changing these values affects how the AI generates responses. The defaults work well for most use cases." : { "localizations" : { @@ -1654,6 +1691,9 @@ } } } + }, + "Choose an agent from the list to view details and run history" : { + }, "Clear All" : { "comment" : "A button to clear all email activity logs.", @@ -1918,6 +1958,9 @@ } } } + }, + "Cost" : { + }, "Cost Examples" : { "comment" : "A heading for the cost examples of a model.", @@ -2092,6 +2135,9 @@ } } } + }, + "Disabled" : { + }, "Each command will require your approval before running." : { "localizations" : { @@ -2468,6 +2514,12 @@ } } } + }, + "Filter by Category" : { + + }, + "Generate an API key in your Jarvis settings and paste it above." : { + }, "Google (Gemini embedding)" : { "localizations" : { @@ -2526,6 +2578,12 @@ } } } + }, + "High (~80%)" : { + + }, + "How to get your API key:" : { + }, "How to get your API token:" : { "comment" : "A heading for a section that describes how to get your API token.", @@ -2640,6 +2698,9 @@ } } } + }, + "Input" : { + }, "Large files inflate the system prompt and may hit token limits." : { "comment" : "A warning displayed when a user adds a large file to a skill.", @@ -2726,6 +2787,9 @@ } } } + }, + "Low (~20%)" : { + }, "Lowercase letters, numbers, and hyphens only. No spaces." : { "comment" : "A description of the format of a shortcut's command.", @@ -2842,6 +2906,9 @@ } } } + }, + "Medium (~50%)" : { + }, "messages" : { "localizations" : { @@ -2870,6 +2937,9 @@ } } } + }, + "Minimal (~10%)" : { + }, "Model Context Protocol" : { "localizations" : { @@ -2928,6 +2998,9 @@ } } } + }, + "Model thinks internally but reasoning is not shown in chat" : { + }, "Multi-provider AI chat client" : { "comment" : "A description of oAI.", @@ -3018,6 +3091,9 @@ } } } + }, + "New Chat" : { + }, "No credit data available" : { "comment" : "A message displayed when there is no credit data available.", @@ -3196,6 +3272,9 @@ } } } + }, + "No runs yet" : { + }, "No shortcuts yet" : { "comment" : "A message displayed when a user has no shortcuts.", @@ -3347,6 +3426,10 @@ } } }, + "oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates." : { + "comment" : "A warning that Intel Macs are no longer supported.", + "isCommentAutoGenerated" : true + }, "Ollama (Local)" : { "comment" : "A label displayed above the credits information for the local Ollie.", "isCommentAutoGenerated" : true, @@ -3574,6 +3657,9 @@ } } } + }, + "OpenRouter Balance" : { + }, "OpenRouter Credits" : { "comment" : "A heading for the user's OpenRouter credits.", @@ -3604,6 +3690,12 @@ } } } + }, + "Output" : { + + }, + "Prompt" : { + }, "Read access (always enabled)" : { "localizations" : { @@ -3632,6 +3724,9 @@ } } } + }, + "Reasoning" : { + }, "Remote: %@" : { "localizations" : { @@ -3690,6 +3785,12 @@ } } } + }, + "Run History" : { + + }, + "Run some agents to see usage statistics" : { + }, "Running locally — no credits needed!" : { "comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.", @@ -3721,6 +3822,10 @@ } } }, + "Runs" : { + "comment" : "A column header for the number of runs.", + "isCommentAutoGenerated" : true + }, "Security Recommendation" : { "localizations" : { "da" : { @@ -3866,6 +3971,9 @@ } } } + }, + "Sort" : { + }, "SSH Key" : { "localizations" : { @@ -4180,6 +4288,9 @@ } } } + }, + "Thinking…" : { + }, "This default prompt is always included to ensure accurate, helpful responses." : { "localizations" : { @@ -4296,6 +4407,15 @@ } } } + }, + "Total" : { + + }, + "Total Credits" : { + + }, + "Total Used" : { + }, "Try adjusting your search or filters" : { "comment" : "A description of the error that occurs when no models match the user's search.", @@ -4444,6 +4564,9 @@ } } } + }, + "Usage" : { + }, "Use @filename to attach files to your message" : { "comment" : "A description of how to attach files to a message.", @@ -4565,6 +4688,7 @@ }, "v%@" : { "comment" : "A label showing the current version of oAI.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "da" : { @@ -4744,6 +4868,10 @@ } } } + }, + "β" : { + "comment" : "A beta badge.", + "isCommentAutoGenerated" : true } }, "version" : "1.1" diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index f689610..ab14649 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -134,15 +134,29 @@ final class DatabaseService: Sendable { nonisolated static let shared = DatabaseService() private let dbQueue: DatabaseQueue - private let isoFormatter: ISO8601DateFormatter // Command history limit - keep most recent 5000 entries - private static let maxHistoryEntries = 5000 + private nonisolated static let maxHistoryEntries = 5000 + + // ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable). + private nonisolated static let isoStyle = Date.ISO8601FormatStyle( + dateSeparator: .dash, + dateTimeSeparator: .standard, + timeSeparator: .colon, + timeZoneSeparator: .colon, + includingFractionalSeconds: true, + timeZone: .gmt + ) + + private nonisolated static func isoString(from date: Date) -> String { + isoStyle.format(date) + } + + private nonisolated static func isoDate(from string: String) -> Date? { + (try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601)) + } nonisolated private init() { - isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let fileManager = FileManager.default let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true) @@ -156,7 +170,7 @@ final class DatabaseService: Sendable { try! migrator.migrate(dbQueue) } - private var migrator: DatabaseMigrator { + private nonisolated var migrator: DatabaseMigrator { var migrator = DatabaseMigrator() migrator.registerMigration("v1") { db in @@ -375,7 +389,7 @@ final class DatabaseService: Sendable { nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation { Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))") let now = Date() - let nowString = isoFormatter.string(from: now) + let nowString = Self.isoString(from: now) let convRecord = ConversationRecord( id: id.uuidString, @@ -394,7 +408,7 @@ final class DatabaseService: Sendable { content: msg.content, tokens: msg.tokens, cost: msg.cost, - timestamp: isoFormatter.string(from: msg.timestamp), + timestamp: Self.isoString(from: msg.timestamp), sortOrder: index, modelId: msg.modelId ) @@ -420,7 +434,7 @@ final class DatabaseService: Sendable { /// Update an existing conversation in-place: rename it, replace all its messages. nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws { - let nowString = isoFormatter.string(from: Date()) + let nowString = Self.isoString(from: Date()) let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in guard msg.role != .system else { return nil } @@ -431,7 +445,7 @@ final class DatabaseService: Sendable { content: msg.content, tokens: msg.tokens, cost: msg.cost, - timestamp: isoFormatter.string(from: msg.timestamp), + timestamp: Self.isoString(from: msg.timestamp), sortOrder: index, modelId: msg.modelId ) @@ -466,7 +480,7 @@ final class DatabaseService: Sendable { let messages = messageRecords.compactMap { record -> Message? in guard let msgId = UUID(uuidString: record.id), let role = MessageRole(rawValue: record.role), - let timestamp = self.isoFormatter.date(from: record.timestamp) + let timestamp = Self.isoDate(from: record.timestamp) else { return nil } let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1 @@ -484,8 +498,8 @@ final class DatabaseService: Sendable { } guard let convId = UUID(uuidString: convRecord.id), - let createdAt = self.isoFormatter.date(from: convRecord.createdAt), - let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt) + let createdAt = Self.isoDate(from: convRecord.createdAt), + let updatedAt = Self.isoDate(from: convRecord.updatedAt) else { return nil } let conversation = Conversation( @@ -509,8 +523,8 @@ final class DatabaseService: Sendable { return records.compactMap { record -> Conversation? in guard let id = UUID(uuidString: record.id), - let createdAt = self.isoFormatter.date(from: record.createdAt), - let updatedAt = self.isoFormatter.date(from: record.updatedAt) + let createdAt = Self.isoDate(from: record.createdAt), + let updatedAt = Self.isoDate(from: record.updatedAt) else { return nil } // Fetch message count without loading all messages @@ -524,7 +538,7 @@ final class DatabaseService: Sendable { .order(Column("sortOrder").desc) .fetchOne(db) - let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt + let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt // Derive primary model: prefer the stored field, fall back to last message's modelId let primaryModel = record.primaryModel ?? lastMsg?.modelId @@ -574,7 +588,7 @@ final class DatabaseService: Sendable { convRecord.name = name } - convRecord.updatedAt = self.isoFormatter.string(from: Date()) + convRecord.updatedAt = Self.isoString(from: Date()) try convRecord.update(db) if let messages = messages { @@ -589,7 +603,7 @@ final class DatabaseService: Sendable { content: msg.content, tokens: msg.tokens, cost: msg.cost, - timestamp: self.isoFormatter.string(from: msg.timestamp), + timestamp: Self.isoString(from: msg.timestamp), sortOrder: index ) } @@ -610,7 +624,7 @@ final class DatabaseService: Sendable { let record = HistoryRecord( id: UUID().uuidString, input: input, - timestamp: isoFormatter.string(from: now) + timestamp: Self.isoString(from: now) ) try? dbQueue.write { db in @@ -643,7 +657,7 @@ final class DatabaseService: Sendable { .fetchAll(db) return records.compactMap { record in - guard let date = isoFormatter.date(from: record.timestamp) else { + guard let date = Self.isoDate(from: record.timestamp) else { return nil } return (input: record.input, timestamp: date) @@ -659,7 +673,7 @@ final class DatabaseService: Sendable { .fetchAll(db) return records.compactMap { record in - guard let date = isoFormatter.date(from: record.timestamp) else { + guard let date = Self.isoDate(from: record.timestamp) else { return nil } return (input: record.input, timestamp: date) @@ -672,7 +686,7 @@ final class DatabaseService: Sendable { nonisolated func saveEmailLog(_ log: EmailLog) { let record = EmailLogRecord( id: log.id.uuidString, - timestamp: isoFormatter.string(from: log.timestamp), + timestamp: Self.isoString(from: log.timestamp), sender: log.sender, subject: log.subject, emailContent: log.emailContent, @@ -698,7 +712,7 @@ final class DatabaseService: Sendable { .fetchAll(db) return records.compactMap { record in - guard let timestamp = isoFormatter.date(from: record.timestamp), + guard let timestamp = Self.isoDate(from: record.timestamp), let status = EmailLogStatus(rawValue: record.status), let id = UUID(uuidString: record.id) else { return nil @@ -805,7 +819,7 @@ final class DatabaseService: Sendable { // MARK: - Embedding Operations nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws { - let now = isoFormatter.string(from: Date()) + let now = Self.isoString(from: Date()) let record = MessageEmbeddingRecord( message_id: messageId.uuidString, embedding: embedding, @@ -825,7 +839,7 @@ final class DatabaseService: Sendable { } nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws { - let now = isoFormatter.string(from: Date()) + let now = Self.isoString(from: Date()) let record = ConversationEmbeddingRecord( conversation_id: conversationId.uuidString, embedding: embedding, @@ -881,7 +895,7 @@ final class DatabaseService: Sendable { return Array(results.prefix(limit)) } - private func deserializeEmbedding(_ data: Data) -> [Float] { + private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] { var embedding: [Float] = [] embedding.reserveCapacity(data.count / 4) @@ -905,7 +919,7 @@ final class DatabaseService: Sendable { model: String?, tokenCount: Int? ) throws { - let now = isoFormatter.string(from: Date()) + let now = Self.isoString(from: Date()) let record = ConversationSummaryRecord( id: UUID().uuidString, conversation_id: conversationId.uuidString, diff --git a/oAI/Services/EmbeddingService.swift b/oAI/Services/EmbeddingService.swift index 391f1d5..29b6f17 100644 --- a/oAI/Services/EmbeddingService.swift +++ b/oAI/Services/EmbeddingService.swift @@ -71,7 +71,7 @@ enum EmbeddingProvider { // MARK: - Embedding Service final class EmbeddingService { - static let shared = EmbeddingService() + nonisolated static let shared = EmbeddingService() private let settings = SettingsService.shared @@ -281,7 +281,7 @@ final class EmbeddingService { // MARK: - Similarity Calculation /// Calculate cosine similarity between two embeddings - func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { + nonisolated func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { guard a.count == b.count else { Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)") return 0.0 diff --git a/oAI/Services/EncryptionService.swift b/oAI/Services/EncryptionService.swift index b23d92c..4ea0864 100644 --- a/oAI/Services/EncryptionService.swift +++ b/oAI/Services/EncryptionService.swift @@ -29,7 +29,7 @@ import CryptoKit import IOKit class EncryptionService { - static let shared = EncryptionService() + nonisolated static let shared = EncryptionService() private let salt = "oAI-secure-storage-v1" // App-specific salt private lazy var encryptionKey: SymmetricKey = { @@ -41,7 +41,7 @@ class EncryptionService { // MARK: - Public Interface /// Encrypt a string value - func encrypt(_ value: String) throws -> String { + nonisolated func encrypt(_ value: String) throws -> String { guard let data = value.data(using: .utf8) else { throw EncryptionError.invalidInput } @@ -55,7 +55,7 @@ class EncryptionService { } /// Decrypt a string value - func decrypt(_ encryptedValue: String) throws -> String { + nonisolated func decrypt(_ encryptedValue: String) throws -> String { guard let data = Data(base64Encoded: encryptedValue) else { throw EncryptionError.invalidInput } diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index 068c4c4..2605f35 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -212,7 +212,7 @@ class GitSyncService { // Check if conversation already exists (by ID) if let existingId = UUID(uuidString: export.id) { - if let existing = try? db.loadConversation(id: existingId) { + if (try? db.loadConversation(id: existingId)) != nil { // Already exists - skip log.debug("Skipping existing conversation: \(export.name)") skipped += 1 diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 67a1403..5191f70 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -300,6 +300,24 @@ class SettingsService { } } + /// Input bar height in points — default 80 + var inputBarHeight: Double { + get { cache["inputBarHeight"].flatMap(Double.init) ?? 80.0 } + set { + cache["inputBarHeight"] = String(newValue) + DatabaseService.shared.setSetting(key: "inputBarHeight", value: String(newValue)) + } + } + + /// Whether the sidebar is visible — default true + var sidebarVisible: Bool { + get { cache["sidebarVisible"] != "false" } + set { + cache["sidebarVisible"] = String(newValue) + DatabaseService.shared.setSetting(key: "sidebarVisible", value: String(newValue)) + } + } + // MARK: - MCP Permissions var mcpCanWriteFiles: Bool { diff --git a/oAI/Services/UpdateCheckService.swift b/oAI/Services/UpdateCheckService.swift index d2d3c44..98f087f 100644 --- a/oAI/Services/UpdateCheckService.swift +++ b/oAI/Services/UpdateCheckService.swift @@ -35,6 +35,11 @@ final class UpdateCheckService { var updateAvailable: Bool = false var latestVersion: String? = nil + var downloadURL: URL? = nil + + // Manual check state — drives the update alert in ContentView + var isCheckingManually: Bool = false + var manualCheckMessage: String? = nil private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest" private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")! @@ -48,6 +53,24 @@ final class UpdateCheckService { } } + /// Manual check triggered from the Help menu. Non-blocking — result surfaces via manualCheckMessage. + func checkForUpdatesManually() { + guard !isCheckingManually else { return } + isCheckingManually = true + Task.detached(priority: .background) { + await self.performCheck() + let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + await MainActor.run { + if self.updateAvailable, let v = self.latestVersion { + self.manualCheckMessage = String(localized: "Version \(v) is available.") + } else { + self.manualCheckMessage = String(localized: "You're up to date (v\(current)).") + } + self.isCheckingManually = false + } + } + } + private func performCheck() async { guard let url = URL(string: apiURL) else { return } @@ -69,9 +92,16 @@ final class UpdateCheckService { let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + // Extract direct DMG download URL from release assets + let dmgURL: URL? = (release["assets"] as? [[String: Any]])? + .first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") } + .flatMap { $0["browser_download_url"] as? String } + .flatMap { URL(string: $0) } + if isNewer(latestVer, than: currentVer) { await MainActor.run { self.latestVersion = latestVer + self.downloadURL = dmgURL self.updateAvailable = true } } diff --git a/oAI/Utilities/Logging.swift b/oAI/Utilities/Logging.swift index 7ca90e1..483fd92 100644 --- a/oAI/Utilities/Logging.swift +++ b/oAI/Utilities/Logging.swift @@ -114,41 +114,43 @@ final class FileLogger: @unchecked Sendable { // MARK: - App Logger (wraps os.Logger + file) -struct AppLogger { - let osLogger: Logger +// os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are +// nonisolated — FileLogger runs on its own serial queue, os.Logger dispatches to main actor. +struct AppLogger: Sendable { + let subsystem: String let category: String - func debug(_ message: String) { + nonisolated func debug(_ message: String) { FileLogger.shared.write(.debug, category: category, message: message) - osLogger.debug("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).debug("\(message, privacy: .public)") } } - func info(_ message: String) { + nonisolated func info(_ message: String) { FileLogger.shared.write(.info, category: category, message: message) - osLogger.info("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).info("\(message, privacy: .public)") } } - func warning(_ message: String) { + nonisolated func warning(_ message: String) { FileLogger.shared.write(.warning, category: category, message: message) - osLogger.warning("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).warning("\(message, privacy: .public)") } } - func error(_ message: String) { + nonisolated func error(_ message: String) { FileLogger.shared.write(.error, category: category, message: message) - osLogger.error("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") } } } // MARK: - Log Namespace enum Log { - private static let subsystem = "com.oai.oAI" + private nonisolated static let subsystem = "com.oai.oAI" - static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api") - static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database") - static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp") - static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings") - static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search") - static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui") - static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general") + nonisolated static let api = AppLogger(subsystem: subsystem, category: "api") + nonisolated static let db = AppLogger(subsystem: subsystem, category: "database") + nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp") + nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings") + nonisolated static let search = AppLogger(subsystem: subsystem, category: "search") + nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui") + nonisolated static let general = AppLogger(subsystem: subsystem, category: "general") } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index ab5a8e9..d398f93 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -1313,7 +1313,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Append the complete system prompt (default + custom) systemContent += "\n\n---\n\n" + effectiveSystemPrompt - var messagesToSend: [Message] = memoryEnabled + let messagesToSend: [Message] = memoryEnabled ? messages.filter { $0.role != .system } : [messages.last(where: { $0.role == .user })].compactMap { $0 } @@ -1659,7 +1659,7 @@ Don't narrate future actions ("Let me...") - just use the tools. if isOverloaded && attempt < maxAttempts && !Task.isCancelled { let delay = Double(1 << attempt) // 2s, 4s, 8s Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...") - await MainActor.run { + _ = await MainActor.run { showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))") } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift index 52180c1..74c6943 100644 --- a/oAI/Views/Main/ChatView.swift +++ b/oAI/Views/Main/ChatView.swift @@ -37,10 +37,6 @@ struct ChatView: View { HeaderView( provider: viewModel.currentProvider, model: viewModel.selectedModel, - stats: viewModel.sessionStats, - onlineMode: viewModel.onlineMode, - mcpEnabled: viewModel.mcpEnabled, - mcpStatus: viewModel.mcpStatus, onModelSelect: onModelSelect, onProviderChange: onProviderChange ) @@ -85,10 +81,13 @@ struct ChatView: View { InputBar( text: $viewModel.inputText, isGenerating: viewModel.isGenerating, - mcpStatus: viewModel.mcpStatus, onlineMode: viewModel.onlineMode, onSend: viewModel.sendMessage, - onCancel: viewModel.cancelGeneration + onCancel: viewModel.cancelGeneration, + onToggleOnline: { + viewModel.onlineMode.toggle() + SettingsService.shared.onlineMode = viewModel.onlineMode + } ) // Footer @@ -96,7 +95,9 @@ struct ChatView: View { stats: viewModel.sessionStats, conversationName: viewModel.currentConversationName, hasUnsavedChanges: viewModel.hasUnsavedChanges, - onQuickSave: viewModel.quickSave + onQuickSave: viewModel.quickSave, + onlineMode: viewModel.onlineMode, + mcpEnabled: viewModel.mcpEnabled ) } .background(Color.oaiBackground) diff --git a/oAI/Views/Main/ContentView.swift b/oAI/Views/Main/ContentView.swift index 28dba53..0876801 100644 --- a/oAI/Views/Main/ContentView.swift +++ b/oAI/Views/Main/ContentView.swift @@ -2,7 +2,7 @@ // ContentView.swift // oAI // -// Root navigation container +// Root navigation container — NavigationSplitView with collapsible sidebar // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen @@ -24,30 +24,34 @@ import SwiftUI +#if os(macOS) +import Darwin // uname, sysctlbyname +#endif struct ContentView: View { @Environment(ChatViewModel.self) var chatViewModel + private var updateService = UpdateCheckService.shared + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var showIntelWarning = false var body: some View { @Bindable var vm = chatViewModel - NavigationStack { + NavigationSplitView(columnVisibility: $columnVisibility) { + SidebarView() + .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340) + } detail: { ChatView( onModelSelect: { chatViewModel.showModelSelector = true }, onProviderChange: { newProvider in chatViewModel.changeProvider(newProvider) } ) - .navigationTitle("") - .toolbar { - #if os(macOS) - macOSToolbar - #endif - } } .frame(minWidth: 860, minHeight: 560) #if os(macOS) .onAppear { NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed } + checkIntelWarning() } .onKeyPress(.return, phases: .down) { press in if press.modifiers.contains(.command) { @@ -65,7 +69,6 @@ struct ContentView: View { let oldModel = chatViewModel.selectedModel chatViewModel.selectModel(model) chatViewModel.showModelSelector = false - // Trigger auto-save on model switch Task { await chatViewModel.onModelSwitch(from: oldModel, to: model) } @@ -113,125 +116,56 @@ struct ContentView: View { chatViewModel.inputText = input }) } + .alert("Intel Mac Support Ending", isPresented: $showIntelWarning) { + Button("Got It") { + UserDefaults.standard.set(true, forKey: "hasShownIntelWarning") + } + } message: { + Text("oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.") + } + .alert("Software Update", isPresented: Binding( + get: { updateService.manualCheckMessage != nil }, + set: { if !$0 { updateService.manualCheckMessage = nil } } + )) { + if updateService.updateAvailable { + if let url = updateService.downloadURL { + Button("Download v\(updateService.latestVersion ?? "")") { + NSWorkspace.shared.open(url) + } + } + Button("Release Page") { updateService.openReleasesPage() } + Button("Later", role: .cancel) { } + } else { + Button("OK", role: .cancel) { } + } + } message: { + Text(updateService.manualCheckMessage ?? "") + } } #if os(macOS) - @ToolbarContentBuilder - private var macOSToolbar: some ToolbarContent { - let settings = SettingsService.shared - let showLabels = settings.showToolbarLabels - let iconSize = settings.toolbarIconSize + private func checkIntelWarning() { + guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return } + guard isIntelNative || isRosetta else { return } + showIntelWarning = true + } - ToolbarItemGroup(placement: .automatic) { - // New conversation - Button(action: { chatViewModel.newConversation() }) { - ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("n", modifiers: .command) - .help("New conversation") - - Button(action: { chatViewModel.showConversations = true }) { - ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("l", modifiers: .command) - .help("Saved conversations (Cmd+L)") - - Button(action: { chatViewModel.showHistory = true }) { - ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("h", modifiers: .command) - .help("Command history (Cmd+H)") - - Spacer() - - Button(action: { chatViewModel.showModelSelector = true }) { - ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("m", modifiers: .command) - .help("Select AI model (Cmd+M)") - - Button(action: { - if let model = chatViewModel.selectedModel { - chatViewModel.modelInfoTarget = model - } - }) { - ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("i", modifiers: .command) - .help("Model info (Cmd+I)") - .disabled(chatViewModel.selectedModel == nil) - - Button(action: { chatViewModel.showStats = true }) { - ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, iconSize: iconSize) - } - .help("Session statistics") - - Button(action: { chatViewModel.showCredits = true }) { - ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, iconSize: iconSize) - } - .help("Check API credits") - - Spacer() - - Button(action: { chatViewModel.showSettings = true }) { - ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut(",", modifiers: .command) - .help("Settings (Cmd+,)") - - Button(action: { chatViewModel.showHelp = true }) { - ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("/", modifiers: .command) - .help("Help & commands (Cmd+/)") + private var isIntelNative: Bool { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { + String(cString: $0.bindMemory(to: CChar.self).baseAddress!) } + return machine.contains("x86_64") + } + + private var isRosetta: Bool { + var ret: Int32 = 0 + var size = MemoryLayout.size + sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0) + return ret == 1 } #endif - -} - -// Helper view for toolbar labels -struct ToolbarLabel: View { - let title: LocalizedStringKey - let systemImage: String - let showLabels: Bool - let iconSize: Double - - // imageScale for the original range (≤32); explicit font size for the new extra-large range (>32) - private var scale: Image.Scale { - switch iconSize { - case ...18: return .small - case 19...24: return .medium - default: return .large - } - } - - var body: some View { - if iconSize > 32 { - // Extra-large: explicit font size above the system .large ceiling - // Offset by 16 so slider 34→18pt, 36→20pt, 38→22pt, 40→24pt - if showLabels { - Label(title, systemImage: systemImage) - .labelStyle(.titleAndIcon) - .font(.system(size: iconSize - 16)) - } else { - Label(title, systemImage: systemImage) - .labelStyle(.iconOnly) - .font(.system(size: iconSize - 16)) - } - } else { - // Original behaviour — imageScale keeps existing look intact - if showLabels { - Label(title, systemImage: systemImage) - .labelStyle(.titleAndIcon) - .imageScale(scale) - } else { - Label(title, systemImage: systemImage) - .labelStyle(.iconOnly) - .imageScale(scale) - } - } - } } #Preview { diff --git a/oAI/Views/Main/FooterView.swift b/oAI/Views/Main/FooterView.swift index 090f200..178be1d 100644 --- a/oAI/Views/Main/FooterView.swift +++ b/oAI/Views/Main/FooterView.swift @@ -30,15 +30,22 @@ struct FooterView: View { let conversationName: String? let hasUnsavedChanges: Bool let onQuickSave: (() -> Void)? + let onlineMode: Bool + let mcpEnabled: Bool + private let settings = SettingsService.shared init(stats: SessionStats, conversationName: String? = nil, hasUnsavedChanges: Bool = false, - onQuickSave: (() -> Void)? = nil) { + onQuickSave: (() -> Void)? = nil, + onlineMode: Bool = false, + mcpEnabled: Bool = false) { self.stats = stats self.conversationName = conversationName self.hasUnsavedChanges = hasUnsavedChanges self.onQuickSave = onQuickSave + self.onlineMode = onlineMode + self.mcpEnabled = mcpEnabled } var body: some View { @@ -71,6 +78,21 @@ struct FooterView: View { Spacer() + // Status pills — Online, MCP, Sync + #if os(macOS) + HStack(spacing: 6) { + if onlineMode { + StatusPill(icon: "globe", label: "Online", color: .green) + } + if mcpEnabled { + StatusPill(icon: "folder", label: "MCP", color: .blue) + } + if settings.syncEnabled && settings.syncAutoSave { + SyncStatusPill() + } + } + #endif + // Save indicator (only when chat has messages) if stats.messageCount > 0 { SaveIndicator( @@ -80,17 +102,11 @@ struct FooterView: View { ) } - // Update available badge + // Update available badge (shows only when an update exists — no version number) #if os(macOS) UpdateBadge() #endif - // Shortcuts hint - #if os(macOS) - Text("⌘N New • ⌘M Model • ⌘S Save") - .font(.caption2) - .foregroundColor(.oaiSecondary) - #endif } .padding(.horizontal, 16) .padding(.vertical, 8) @@ -242,7 +258,6 @@ struct SyncStatusFooter: View { struct UpdateBadge: View { private let updater = UpdateCheckService.shared - private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" var body: some View { if updater.updateAvailable { @@ -258,10 +273,6 @@ struct UpdateBadge: View { } .buttonStyle(.plain) .help("A new version is available — click to open the releases page") - } else { - Text("v\(currentVersion)") - .font(.caption2) - .foregroundColor(.oaiSecondary) } } } diff --git a/oAI/Views/Main/HeaderView.swift b/oAI/Views/Main/HeaderView.swift index 741be09..4280a9d 100644 --- a/oAI/Views/Main/HeaderView.swift +++ b/oAI/Views/Main/HeaderView.swift @@ -2,7 +2,8 @@ // HeaderView.swift // oAI // -// Header bar with provider, model, and stats +// Slim header — provider, model name, star only. +// Status pills and stats live in SidebarView and FooterView respectively. // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen @@ -28,18 +29,13 @@ import SwiftUI struct HeaderView: View { let provider: Settings.Provider let model: ModelInfo? - let stats: SessionStats - let onlineMode: Bool - let mcpEnabled: Bool - let mcpStatus: String? let onModelSelect: () -> Void let onProviderChange: (Settings.Provider) -> Void private let settings = SettingsService.shared private let registry = ProviderRegistry.shared - private let gitSync = GitSyncService.shared var body: some View { - HStack(spacing: 20) { + HStack(spacing: 12) { // Provider picker dropdown — only shows configured providers Menu { ForEach(registry.configuredProviders, id: \.self) { p in @@ -75,7 +71,7 @@ struct HeaderView: View { .fixedSize() .help("Switch provider") - // Model info (clickable → model selector) + // Model name (clickable → model selector) Button(action: onModelSelect) { if let model = model { HStack(spacing: 6) { @@ -116,7 +112,6 @@ struct HeaderView: View { Text("No model selected") .font(.system(size: settings.guiTextSize)) .foregroundColor(.oaiSecondary) - Image(systemName: "chevron.down") .font(.caption2) .foregroundColor(.oaiSecondary) @@ -126,6 +121,7 @@ struct HeaderView: View { .buttonStyle(.plain) .help("Select model") + // Favourite star if let model = model { let isFav = settings.favoriteModelIds.contains(model.id) Button(action: { settings.toggleFavoriteModel(model.id) }) { @@ -138,40 +134,9 @@ struct HeaderView: View { } Spacer() - - // Status indicators - HStack(spacing: 8) { - if model?.capabilities.imageGeneration == true { - StatusPill(icon: "paintbrush", label: "Image", color: .purple) - } - if onlineMode { - StatusPill(icon: "globe", label: "Online", color: .green) - } - if mcpEnabled { - StatusPill(icon: "folder", label: "MCP", color: .blue) - } - if settings.syncEnabled && settings.syncAutoSave { - SyncStatusPill() - } - } - - // Divider between status and stats - if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) { - Divider() - .frame(height: 16) - .opacity(0.5) - } - - // Quick stats - HStack(spacing: 16) { - StatItem(icon: "message", value: "\(stats.messageCount)") - StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay) - StatItem(icon: "dollarsign", value: stats.totalCostDisplay) - } - .font(.caption) } .padding(.horizontal, 16) - .padding(.vertical, 10) + .padding(.vertical, 8) .background(.ultraThinMaterial) .overlay( Rectangle() @@ -182,22 +147,7 @@ struct HeaderView: View { } } -struct StatItem: View { - let icon: String - let value: String - private let settings = SettingsService.shared - - var body: some View { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: settings.guiTextSize - 3)) - .foregroundColor(.oaiSecondary) - Text(value) - .font(.system(size: settings.guiTextSize - 1, weight: .medium)) - .foregroundColor(.oaiPrimary) - } - } -} +// MARK: - Status Pills (used by SidebarView) struct StatusPill: View { let icon: String @@ -284,15 +234,6 @@ struct SyncStatusPill: View { HeaderView( provider: .openrouter, model: ModelInfo.mockModels.first, - stats: SessionStats( - totalInputTokens: 125, - totalOutputTokens: 434, - totalCost: 0.00111, - messageCount: 4 - ), - onlineMode: true, - mcpEnabled: true, - mcpStatus: "MCP", onModelSelect: {}, onProviderChange: { _ in } ) diff --git a/oAI/Views/Main/InputBar.swift b/oAI/Views/Main/InputBar.swift index 7bc7181..9985223 100644 --- a/oAI/Views/Main/InputBar.swift +++ b/oAI/Views/Main/InputBar.swift @@ -2,7 +2,7 @@ // InputBar.swift // oAI // -// Message input bar with status indicators +// Message input bar with resizable height and online toggle // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen @@ -24,20 +24,31 @@ import SwiftUI +#if os(macOS) +import AppKit +#endif struct InputBar: View { @Binding var text: String let isGenerating: Bool - let mcpStatus: String? let onlineMode: Bool let onSend: () -> Void let onCancel: () -> Void + let onToggleOnline: () -> Void private let settings = SettingsService.shared + + // Resizable input height — persisted to settings + @State private var inputHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight) + @State private var dragStartHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight) + @State private var showCommandDropdown = false @State private var selectedSuggestionIndex: Int = 0 @FocusState private var isInputFocused: Bool + private static let minInputHeight: CGFloat = 56 + private static let maxInputHeight: CGFloat = 320 + /// Commands that execute immediately without additional arguments private static let immediateCommands: Set = [ "/help", "/history", "/model", "/clear", "/retry", "/stats", "/config", @@ -56,48 +67,42 @@ struct InputBar: View { CommandSuggestionsView( searchText: text, selectedIndex: selectedSuggestionIndex, - onSelect: { command in - selectCommand(command) - } + onSelect: selectCommand ) .frame(width: 400) .frame(maxHeight: 200) .transition(.move(edge: .bottom).combined(with: .opacity)) Spacer() } - .padding(.leading, 96) // Align with input box (status badges + spacing) + .padding(.leading, 16) } - // Input area - HStack(alignment: .bottom, spacing: 12) { - // Status indicators - HStack(spacing: 6) { - if let mcp = mcpStatus { - StatusBadge(text: mcp, color: .blue) - } - if onlineMode { - StatusBadge(text: "🌐", color: .green) - } - } - .frame(width: 80, alignment: .leading) + // Drag-to-resize handle + dragHandle - // Text input + // Input row + HStack(alignment: .bottom, spacing: 12) { + // Text input with globe toggle in bottom-left corner ZStack(alignment: .topLeading) { + // Placeholder if text.isEmpty { Text("Type a message or / for commands...") .font(.system(size: settings.inputTextSize)) .foregroundColor(.oaiSecondary) .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.top, 10) + .allowsHitTesting(false) } + // Editor — fills the fixed-height box, bottom area reserved for globe TextEditor(text: $text) .font(.system(size: settings.inputTextSize)) .foregroundColor(.oaiPrimary) .scrollContentBackground(.hidden) - .frame(minHeight: 44, maxHeight: 120) .padding(.horizontal, 8) - .padding(.vertical, 6) + .padding(.top, 6) + .padding(.bottom, 30) + .frame(maxWidth: .infinity, maxHeight: .infinity) .focused($isInputFocused) .onChange(of: text) { showCommandDropdown = text.hasPrefix("/") @@ -105,7 +110,6 @@ struct InputBar: View { } #if os(macOS) .onKeyPress(.upArrow) { - // Navigate command dropdown if showCommandDropdown && selectedSuggestionIndex > 0 { selectedSuggestionIndex -= 1 return .handled @@ -113,7 +117,6 @@ struct InputBar: View { return .ignored } .onKeyPress(.downArrow) { - // Navigate command dropdown if showCommandDropdown { let count = CommandSuggestionsView.filteredCommands(for: text).count if selectedSuggestionIndex < count - 1 { @@ -124,25 +127,12 @@ struct InputBar: View { return .ignored } .onKeyPress(.escape) { - // If command dropdown is showing, close it - if showCommandDropdown { - showCommandDropdown = false - return .handled - } - // If model is generating, cancel it - if isGenerating { - onCancel() - return .handled - } + if showCommandDropdown { showCommandDropdown = false; return .handled } + if isGenerating { onCancel(); return .handled } return .ignored } .onKeyPress(.return, phases: .down) { press in - // Shift+Return: always insert newline (let system handle) - if press.modifiers.contains(.shift) { - return .ignored - } - - // If command dropdown is showing, select the highlighted command + if press.modifiers.contains(.shift) { return .ignored } if showCommandDropdown { let suggestions = CommandSuggestionsView.filteredCommands(for: text) if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count { @@ -150,27 +140,40 @@ struct InputBar: View { return .handled } } - // Return (plain or with Cmd): send message - if !text.isEmpty { - onSend() - return .handled - } - // Empty text: do nothing + if !text.isEmpty { onSend(); return .handled } return .handled } #endif + + // Online / offline toggle — bottom-left of the text box + VStack { + Spacer() + HStack { + Button(action: onToggleOnline) { + Image(systemName: onlineMode ? "globe" : "network.slash") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(onlineMode ? Color.green : Color.secondary) + .padding(8) + } + .buttonStyle(.plain) + .help(onlineMode + ? "Online mode on — click to go offline" + : "Offline — click to go online") + Spacer() + } + } } + .frame(height: inputHeight) .background(Color.oaiSurface) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1) ) - - // Action buttons + + // Send / stop + attach buttons VStack(spacing: 8) { #if os(macOS) - // File attach button Button(action: pickFile) { Image(systemName: "paperclip") .font(.title2) @@ -209,21 +212,47 @@ struct InputBar: View { } } + // MARK: - Drag handle + + private var dragHandle: some View { + Color.clear + .frame(height: 8) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .overlay { + Capsule() + .fill(Color.secondary.opacity(0.25)) + .frame(width: 36, height: 3) + } + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + let proposed = dragStartHeight - value.translation.height + inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed)) + } + .onEnded { _ in + dragStartHeight = inputHeight + settings.inputBarHeight = Double(inputHeight) + } + ) + #if os(macOS) + .onHover { hovering in + if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() } + } + #endif + } + + // MARK: - Helpers + private func selectCommand(_ command: String) { showCommandDropdown = false if Self.immediateCommands.contains(command) { - // Execute immediately text = command onSend() } else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) { - if shortcut.needsInput { - text = command + " " - } else { - text = command - onSend() - } + text = shortcut.needsInput ? command + " " : command + if !shortcut.needsInput { onSend() } } else { - // Put in input for user to complete text = command + " " } } @@ -235,36 +264,14 @@ struct InputBar: View { panel.canChooseDirectories = false panel.canChooseFiles = true panel.message = "Select files to attach" - guard panel.runModal() == .OK else { return } - - let paths = panel.urls.map { $0.path } - // Use @ format (angle brackets) to safely handle paths with spaces - let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ") - - if text.isEmpty { - text = attachmentText + " " - } else { - text += " " + attachmentText - } + let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ") + text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText } #endif } -struct StatusBadge: View { - let text: String - let color: Color - - var body: some View { - Text(text) - .font(.caption) - .foregroundColor(color) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(color.opacity(0.15)) - .cornerRadius(4) - } -} +// MARK: - Command suggestions struct CommandSuggestionsView: View { let searchText: String @@ -304,10 +311,9 @@ struct CommandSuggestionsView: View { ] static func allCommands() -> [(command: String, description: LocalizedStringKey)] { - let shortcuts = SettingsService.shared.userShortcuts.map { s in + SettingsService.shared.userShortcuts.map { s in (s.command, LocalizedStringKey("⚡ \(s.description)")) - } - return builtInCommands + shortcuts + } + builtInCommands } static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] { @@ -344,26 +350,20 @@ struct CommandSuggestionsView: View { .id(suggestion.command) if index < suggestions.count - 1 { - Divider() - .background(Color.oaiBorder) + Divider().background(Color.oaiBorder) } } } } .onChange(of: selectedIndex) { if selectedIndex < suggestions.count { - withAnimation { - proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) - } + withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) } } } } .background(Color.oaiSurface) .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.oaiBorder, lineWidth: 1) - ) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1)) } } @@ -373,10 +373,10 @@ struct CommandSuggestionsView: View { InputBar( text: .constant(""), isGenerating: false, - mcpStatus: "📁 Files", onlineMode: true, onSend: {}, - onCancel: {} + onCancel: {}, + onToggleOnline: {} ) } .background(Color.oaiBackground) diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift new file mode 100644 index 0000000..eb73e14 --- /dev/null +++ b/oAI/Views/Main/SidebarView.swift @@ -0,0 +1,216 @@ +// +// SidebarView.swift +// oAI +// +// Collapsible sidebar: new chat, conversation list, status pills +// +// 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 . + + +import SwiftUI +#if os(macOS) +import AppKit +#endif + +struct SidebarView: View { + @Environment(ChatViewModel.self) private var chatViewModel + @State private var conversations: [Conversation] = [] + @State private var searchText = "" + + private var filteredConversations: [Conversation] { + guard !searchText.isEmpty else { return conversations } + return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) } + } + + var body: some View { + VStack(spacing: 0) { + // New Chat button + Button(action: { chatViewModel.newConversation() }) { + HStack(spacing: 8) { + Image(systemName: "square.and.pencil") + .font(.system(size: 14)) + Text("New Chat") + .font(.system(size: 14, weight: .medium)) + Spacer() + } + .foregroundColor(.oaiPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + // Search field + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + TextField("Search conversations…", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 13)) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + } + Divider().frame(height: 12) + Button { + chatViewModel.showConversations = true + } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Advanced search — semantic search, bulk delete, export") + } + .padding(7) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) + .padding(.horizontal, 8) + .padding(.bottom, 6) + + Divider() + + // Conversation list + if filteredConversations.isEmpty { + Spacer() + VStack(spacing: 8) { + Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass") + .font(.title2) + .foregroundStyle(.tertiary) + Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches") + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + } else { + List { + ForEach(filteredConversations) { conversation in + SidebarConversationRow(conversation: conversation) + .contentShape(Rectangle()) + .onTapGesture { + chatViewModel.loadConversation(conversation) + } + .listRowBackground( + chatViewModel.currentConversationName == conversation.name + ? Color.oaiAccent.opacity(0.15) + : Color.clear + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + deleteConversation(conversation) + } label: { + Label("Delete", systemImage: "trash") + } + Button { + renameConversation(conversation) + } label: { + Label("Rename", systemImage: "pencil") + } + .tint(.orange) + } + } + } + .listStyle(.sidebar) + } + + } + .onAppear { loadConversations() } + .onChange(of: chatViewModel.currentConversationName) { loadConversations() } + .onChange(of: chatViewModel.messages.count) { loadConversations() } + } + + private func loadConversations() { + conversations = (try? DatabaseService.shared.listConversations()) ?? [] + } + + private func deleteConversation(_ conversation: Conversation) { + _ = try? DatabaseService.shared.deleteConversation(id: conversation.id) + withAnimation { + conversations.removeAll { $0.id == conversation.id } + } + } + + private func renameConversation(_ conversation: Conversation) { + #if os(macOS) + let alert = NSAlert() + alert.messageText = "Rename Conversation" + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + input.stringValue = conversation.name + input.selectText(nil) + alert.accessoryView = input + alert.window.initialFirstResponder = input + guard alert.runModal() == .alertFirstButtonReturn else { return } + let newName = input.stringValue.trimmingCharacters(in: .whitespaces) + guard !newName.isEmpty, newName != conversation.name else { return } + do { + _ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil) + if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { + conversations[i].name = newName + conversations[i].updatedAt = Date() + } + chatViewModel.didRenameConversation(id: conversation.id, newName: newName) + } catch { + Log.db.error("Failed to rename conversation: \(error.localizedDescription)") + } + #endif + } +} + +// MARK: - Sidebar conversation row + +struct SidebarConversationRow: View { + let conversation: Conversation + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM.yyyy" + return formatter.string(from: conversation.updatedAt) + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(conversation.name) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + HStack(spacing: 4) { + Text("^[\(conversation.messageCount) message](inflect: true)") + .font(.system(size: 11)) + Text("·") + .font(.system(size: 11)) + Text(formattedDate) + .font(.system(size: 11)) + } + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } +} + +#Preview { + SidebarView() + .environment(ChatViewModel()) + .frame(width: 240, height: 600) +} diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index abdd8b6..2717fe3 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -92,6 +92,8 @@ struct oAIApp: App { CommandGroup(after: .newItem) { Button("Open Chat…") { chatViewModel.showConversations = true } .keyboardShortcut("o", modifiers: .command) + Button("Search Conversations") { chatViewModel.showConversations = true } + .keyboardShortcut("l", modifiers: .command) } CommandGroup(replacing: .saveItem) { @@ -113,10 +115,44 @@ struct oAIApp: App { .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) } + // ── View menu ───────────────────────────────────────────────── + CommandMenu("View") { + Button("Select Model") { chatViewModel.showModelSelector = true } + .keyboardShortcut("m", modifiers: .command) + + Button("Model Info") { + chatViewModel.modelInfoTarget = chatViewModel.selectedModel + } + .keyboardShortcut("i", modifiers: .command) + .disabled(chatViewModel.selectedModel == nil) + + Divider() + + Button("Command History") { chatViewModel.showHistory = true } + .keyboardShortcut("h", modifiers: .command) + + Button("In-App Help") { chatViewModel.showHelp = true } + .keyboardShortcut("/", modifiers: .command) + + Button("Credits") { chatViewModel.showCredits = true } + + Divider() + + Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") { + chatViewModel.onlineMode.toggle() + } + .keyboardShortcut("o", modifiers: [.command, .shift]) + } + // ── Help menu ───────────────────────────────────────────────── CommandGroup(replacing: .help) { Button("oAI Help") { openHelp() } .keyboardShortcut("?", modifiers: .command) + Divider() + Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") { + UpdateCheckService.shared.checkForUpdatesManually() + } + .disabled(UpdateCheckService.shared.isCheckingManually) } } #endif From f3a0c45331e43bcfc417c76b6efe3c3ee579f497 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 16 Jun 2026 11:36:55 +0200 Subject: [PATCH 02/14] =?UTF-8?q?Add=20Apple=20Intelligence=20provider=20(?= =?UTF-8?q?Phase=201=20=E2=80=94=20on-device)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New AppleFoundationProvider using FoundationModels framework (macOS 27+) - Streaming via streamResponse(to:) → ResponseStream snapshot deltas - Session built with system prompt + conversation history injected as instructions text - Full error mapping: context exceeded, guardrail violation, rate limit, availability states - Settings.Provider.appleOnDevice case wired through ProviderRegistry, Color+Extensions, CreditsView - inferProvider() detects "apple-" prefix model IDs - Settings → General: Apple Intelligence section with live availability badge and deep link to System Settings Co-Authored-By: Claude Sonnet 4.6 --- oAI/Models/Settings.swift | 22 +- oAI/Providers/AppleFoundationProvider.swift | 195 ++++++++++++++++++ oAI/Providers/ProviderRegistry.swift | 5 + .../Extensions/Color+Extensions.swift | 9 +- oAI/ViewModels/ChatViewModel.swift | 2 + oAI/Views/Screens/CreditsView.swift | 11 + oAI/Views/Screens/SettingsView.swift | 46 +++++ 7 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 oAI/Providers/AppleFoundationProvider.swift diff --git a/oAI/Models/Settings.swift b/oAI/Models/Settings.swift index f2358d2..af46f2c 100644 --- a/oAI/Models/Settings.swift +++ b/oAI/Models/Settings.swift @@ -58,17 +58,25 @@ struct Settings: Codable { case anthropic case openai case ollama - + case appleOnDevice = "apple_on_device" + var displayName: String { - rawValue.capitalized + switch self { + case .openrouter: return "OpenRouter" + case .anthropic: return "Anthropic" + case .openai: return "OpenAI" + case .ollama: return "Ollama" + case .appleOnDevice: return "Apple Intelligence" + } } - + var iconName: String { switch self { - case .openrouter: return "network" - case .anthropic: return "brain" - case .openai: return "sparkles" - case .ollama: return "server.rack" + case .openrouter: return "network" + case .anthropic: return "brain" + case .openai: return "sparkles" + case .ollama: return "server.rack" + case .appleOnDevice: return "apple.logo" } } } diff --git a/oAI/Providers/AppleFoundationProvider.swift b/oAI/Providers/AppleFoundationProvider.swift new file mode 100644 index 0000000..340f0b7 --- /dev/null +++ b/oAI/Providers/AppleFoundationProvider.swift @@ -0,0 +1,195 @@ +// +// AppleFoundationProvider.swift +// oAI +// +// Apple Foundation Models provider (on-device Apple Intelligence) +// +// 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 . + + +import Foundation +import FoundationModels +import os + +final class AppleFoundationProvider: AIProvider { + let name = "Apple Intelligence" + + let capabilities = ProviderCapabilities( + supportsStreaming: true, + supportsVision: false, + supportsTools: false, + supportsOnlineSearch: false, + maxContextLength: 4096 + ) + + // MARK: - Models + + func listModels() async throws -> [ModelInfo] { + [ + ModelInfo( + id: "apple-on-device", + name: "Apple On-Device", + description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window.", + contextLength: 4096, + pricing: ModelInfo.Pricing(prompt: 0, completion: 0), + capabilities: ModelInfo.ModelCapabilities( + vision: false, + tools: false, + online: false + ) + ) + ] + } + + func getModel(_ id: String) async throws -> ModelInfo? { + try await listModels().first { $0.id == id } + } + + func getCredits() async throws -> Credits? { nil } + + // MARK: - Streaming chat + + func streamChat(request: ChatRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let session = try self.makeSession(for: request) + let prompt = self.lastUserMessage(from: request) + + // streamResponse(to: String) → ResponseStream + // Each snapshot.content is the full accumulated text so far (snapshot model). + // We compute deltas by comparing each snapshot to the previous. + let stream = session.streamResponse(to: prompt) + var lastContent = "" + + for try await snapshot in stream { + let current = snapshot.content + if current.count > lastContent.count { + let delta = String(current.dropFirst(lastContent.count)) + continuation.yield(StreamChunk( + id: UUID().uuidString, + model: request.model, + delta: StreamChunk.Delta(content: delta, role: "assistant"), + finishReason: nil, + usage: nil + )) + lastContent = current + } + } + + continuation.yield(StreamChunk( + id: UUID().uuidString, + model: request.model, + delta: StreamChunk.Delta(content: nil, role: nil), + finishReason: "stop", + usage: nil + )) + continuation.finish() + + } catch let genError as LanguageModelSession.GenerationError { + continuation.finish(throwing: self.mapGenerationError(genError)) + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - Non-streaming chat + + func chat(request: ChatRequest) async throws -> ChatResponse { + let session = try makeSession(for: request) + let prompt = lastUserMessage(from: request) + let response: LanguageModelSession.Response = try await session.respond(to: prompt) + return ChatResponse( + id: UUID().uuidString, + model: request.model, + content: response.content, + role: "assistant", + finishReason: "stop", + usage: nil, + created: Date() + ) + } + + // MARK: - Tool messages (not supported in Phase 1) + + func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse { + throw ProviderError.unknown("Tool calling requires Apple Foundation Models Phase 3.") + } + + // MARK: - Session construction + + private func makeSession(for request: ChatRequest) throws -> LanguageModelSession { + guard case .available = SystemLanguageModel.default.availability else { + throw availabilityError() + } + + // Build instructions: system prompt + prior conversation turns as formatted text. + // Foundation Models sessions don't accept a message array — we inject history inline. + var instructions = request.systemPrompt ?? "" + let priorMessages = request.messages.dropLast().filter { $0.role != .system } + + if !priorMessages.isEmpty { + let history = priorMessages + .map { m -> String in + let label = m.role == .user ? "User" : "Assistant" + return "\(label): \(m.content)" + } + .joined(separator: "\n") + instructions += "\n\nConversation so far:\n\(history)\n\nContinue from here." + } + + return instructions.isEmpty + ? LanguageModelSession() + : LanguageModelSession(instructions: instructions) + } + + private func lastUserMessage(from request: ChatRequest) -> String { + request.messages.last(where: { $0.role == .user })?.content ?? "" + } + + // MARK: - Error mapping + + private func availabilityError() -> Error { + switch SystemLanguageModel.default.availability { + case .unavailable(.deviceNotEligible): + return ProviderError.unknown("This Mac doesn't support Apple Intelligence. Apple Silicon is required.") + case .unavailable(.appleIntelligenceNotEnabled): + return ProviderError.unknown("Apple Intelligence is not enabled. Open System Settings → Apple Intelligence to turn it on.") + case .unavailable(.modelNotReady): + return ProviderError.unknown("Apple Intelligence model is still downloading. Please wait and try again.") + default: + return ProviderError.unknown("Apple Intelligence is not available on this device.") + } + } + + private func mapGenerationError(_ error: LanguageModelSession.GenerationError) -> Error { + switch error { + case .exceededContextWindowSize: + return ProviderError.unknown("Apple Intelligence context limit exceeded (4,096 tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.") + case .rateLimited: + return ProviderError.rateLimitExceeded + case .guardrailViolation: + return ProviderError.unknown("Apple Intelligence declined to respond to this message.") + default: + return error + } + } +} diff --git a/oAI/Providers/ProviderRegistry.swift b/oAI/Providers/ProviderRegistry.swift index 9785a62..e0d3aa6 100644 --- a/oAI/Providers/ProviderRegistry.swift +++ b/oAI/Providers/ProviderRegistry.swift @@ -69,6 +69,9 @@ class ProviderRegistry { case .ollama: provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL) + + case .appleOnDevice: + provider = AppleFoundationProvider() } // Cache and return @@ -106,6 +109,8 @@ class ProviderRegistry { return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty case .ollama: return settings.ollamaConfigured + case .appleOnDevice: + return true // no API key needed } } diff --git a/oAI/Utilities/Extensions/Color+Extensions.swift b/oAI/Utilities/Extensions/Color+Extensions.swift index e36d750..6c790a1 100644 --- a/oAI/Utilities/Extensions/Color+Extensions.swift +++ b/oAI/Utilities/Extensions/Color+Extensions.swift @@ -60,10 +60,11 @@ extension Color { static func providerColor(_ provider: Settings.Provider) -> Color { switch provider { - case .openrouter: return Color(hex: "#7c3aed") // Purple - case .anthropic: return Color(hex: "#d4895a") // Orange - case .openai: return Color(hex: "#10a37f") // Green - case .ollama: return Color(hex: "#ffffff") // White + case .openrouter: return Color(hex: "#7c3aed") // Purple + case .anthropic: return Color(hex: "#d4895a") // Orange + case .openai: return Color(hex: "#10a37f") // Green + case .ollama: return Color(hex: "#ffffff") // White + case .appleOnDevice: return Color(hex: "#636366") // Apple grey } } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index d398f93..31695ba 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -424,6 +424,8 @@ Don't narrate future actions ("Let me...") - just use the tools. func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) } private func inferProvider(from modelId: String) -> Settings.Provider? { + // Apple Foundation Models + if modelId.hasPrefix("apple-") { return .appleOnDevice } // OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet") if modelId.contains("/") { return .openrouter } // Anthropic direct (e.g. "claude-sonnet-4-5-20250929") diff --git a/oAI/Views/Screens/CreditsView.swift b/oAI/Views/Screens/CreditsView.swift index b60bdd4..a3a2298 100644 --- a/oAI/Views/Screens/CreditsView.swift +++ b/oAI/Views/Screens/CreditsView.swift @@ -80,6 +80,17 @@ struct CreditsView: View { .font(.system(size: 40)) .foregroundColor(.green) .padding(.top) + + case .appleOnDevice: + Text("Apple Intelligence") + .font(.headline) + Text("On-device and free — no credits or API key needed.") + .font(.body) + .foregroundColor(.secondary) + Image(systemName: "apple.logo") + .font(.system(size: 40)) + .foregroundColor(.secondary) + .padding(.top) } } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index aed4728..67d4c72 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -25,6 +25,7 @@ import SwiftUI import UniformTypeIdentifiers +import FoundationModels struct SettingsView: View { @Environment(\.dismiss) var dismiss @@ -306,6 +307,29 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } + // Apple Intelligence + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Apple Intelligence") + formSection { + row("Status") { + appleIntelligenceStatusBadge + } + rowDivider() + row("Model") { + Text("On-Device (4K context)") + .foregroundStyle(.secondary) + } + rowDivider() + row("") { + Button("Open Apple Intelligence Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.aisettings") { + NSWorkspace.shared.open(url) + } + } + } + } + } + // Features VStack(alignment: .leading, spacing: 6) { sectionHeader("Features") @@ -2649,6 +2673,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak formatter.unitsStyle = .full return formatter.localizedString(for: date, relativeTo: .now) } + + @ViewBuilder + private var appleIntelligenceStatusBadge: some View { + let availability = SystemLanguageModel.default.availability + switch availability { + case .available: + Label("Available", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + case .unavailable(.deviceNotEligible): + Label("Not supported on this Mac", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + case .unavailable(.appleIntelligenceNotEnabled): + Label("Not enabled — open Apple Intelligence Settings", systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + case .unavailable(.modelNotReady): + Label("Model downloading…", systemImage: "arrow.down.circle.fill") + .foregroundStyle(.orange) + default: + Label("Unavailable", systemImage: "questionmark.circle.fill") + .foregroundStyle(.secondary) + } + } } #Preview { From f63226b2ccbd94f54da4c3b80fff560315d6e02d Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 16 Jun 2026 11:42:51 +0200 Subject: [PATCH 03/14] =?UTF-8?q?Revert=20"Add=20Apple=20Intelligence=20pr?= =?UTF-8?q?ovider=20(Phase=201=20=E2=80=94=20on-device)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f3a0c45331e43bcfc417c76b6efe3c3ee579f497. --- oAI/Models/Settings.swift | 22 +- oAI/Providers/AppleFoundationProvider.swift | 195 ------------------ oAI/Providers/ProviderRegistry.swift | 5 - .../Extensions/Color+Extensions.swift | 9 +- oAI/ViewModels/ChatViewModel.swift | 2 - oAI/Views/Screens/CreditsView.swift | 11 - oAI/Views/Screens/SettingsView.swift | 46 ----- 7 files changed, 11 insertions(+), 279 deletions(-) delete mode 100644 oAI/Providers/AppleFoundationProvider.swift diff --git a/oAI/Models/Settings.swift b/oAI/Models/Settings.swift index af46f2c..f2358d2 100644 --- a/oAI/Models/Settings.swift +++ b/oAI/Models/Settings.swift @@ -58,25 +58,17 @@ struct Settings: Codable { case anthropic case openai case ollama - case appleOnDevice = "apple_on_device" - + var displayName: String { - switch self { - case .openrouter: return "OpenRouter" - case .anthropic: return "Anthropic" - case .openai: return "OpenAI" - case .ollama: return "Ollama" - case .appleOnDevice: return "Apple Intelligence" - } + rawValue.capitalized } - + var iconName: String { switch self { - case .openrouter: return "network" - case .anthropic: return "brain" - case .openai: return "sparkles" - case .ollama: return "server.rack" - case .appleOnDevice: return "apple.logo" + case .openrouter: return "network" + case .anthropic: return "brain" + case .openai: return "sparkles" + case .ollama: return "server.rack" } } } diff --git a/oAI/Providers/AppleFoundationProvider.swift b/oAI/Providers/AppleFoundationProvider.swift deleted file mode 100644 index 340f0b7..0000000 --- a/oAI/Providers/AppleFoundationProvider.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// AppleFoundationProvider.swift -// oAI -// -// Apple Foundation Models provider (on-device Apple Intelligence) -// -// 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 . - - -import Foundation -import FoundationModels -import os - -final class AppleFoundationProvider: AIProvider { - let name = "Apple Intelligence" - - let capabilities = ProviderCapabilities( - supportsStreaming: true, - supportsVision: false, - supportsTools: false, - supportsOnlineSearch: false, - maxContextLength: 4096 - ) - - // MARK: - Models - - func listModels() async throws -> [ModelInfo] { - [ - ModelInfo( - id: "apple-on-device", - name: "Apple On-Device", - description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window.", - contextLength: 4096, - pricing: ModelInfo.Pricing(prompt: 0, completion: 0), - capabilities: ModelInfo.ModelCapabilities( - vision: false, - tools: false, - online: false - ) - ) - ] - } - - func getModel(_ id: String) async throws -> ModelInfo? { - try await listModels().first { $0.id == id } - } - - func getCredits() async throws -> Credits? { nil } - - // MARK: - Streaming chat - - func streamChat(request: ChatRequest) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - Task { - do { - let session = try self.makeSession(for: request) - let prompt = self.lastUserMessage(from: request) - - // streamResponse(to: String) → ResponseStream - // Each snapshot.content is the full accumulated text so far (snapshot model). - // We compute deltas by comparing each snapshot to the previous. - let stream = session.streamResponse(to: prompt) - var lastContent = "" - - for try await snapshot in stream { - let current = snapshot.content - if current.count > lastContent.count { - let delta = String(current.dropFirst(lastContent.count)) - continuation.yield(StreamChunk( - id: UUID().uuidString, - model: request.model, - delta: StreamChunk.Delta(content: delta, role: "assistant"), - finishReason: nil, - usage: nil - )) - lastContent = current - } - } - - continuation.yield(StreamChunk( - id: UUID().uuidString, - model: request.model, - delta: StreamChunk.Delta(content: nil, role: nil), - finishReason: "stop", - usage: nil - )) - continuation.finish() - - } catch let genError as LanguageModelSession.GenerationError { - continuation.finish(throwing: self.mapGenerationError(genError)) - } catch { - continuation.finish(throwing: error) - } - } - } - } - - // MARK: - Non-streaming chat - - func chat(request: ChatRequest) async throws -> ChatResponse { - let session = try makeSession(for: request) - let prompt = lastUserMessage(from: request) - let response: LanguageModelSession.Response = try await session.respond(to: prompt) - return ChatResponse( - id: UUID().uuidString, - model: request.model, - content: response.content, - role: "assistant", - finishReason: "stop", - usage: nil, - created: Date() - ) - } - - // MARK: - Tool messages (not supported in Phase 1) - - func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse { - throw ProviderError.unknown("Tool calling requires Apple Foundation Models Phase 3.") - } - - // MARK: - Session construction - - private func makeSession(for request: ChatRequest) throws -> LanguageModelSession { - guard case .available = SystemLanguageModel.default.availability else { - throw availabilityError() - } - - // Build instructions: system prompt + prior conversation turns as formatted text. - // Foundation Models sessions don't accept a message array — we inject history inline. - var instructions = request.systemPrompt ?? "" - let priorMessages = request.messages.dropLast().filter { $0.role != .system } - - if !priorMessages.isEmpty { - let history = priorMessages - .map { m -> String in - let label = m.role == .user ? "User" : "Assistant" - return "\(label): \(m.content)" - } - .joined(separator: "\n") - instructions += "\n\nConversation so far:\n\(history)\n\nContinue from here." - } - - return instructions.isEmpty - ? LanguageModelSession() - : LanguageModelSession(instructions: instructions) - } - - private func lastUserMessage(from request: ChatRequest) -> String { - request.messages.last(where: { $0.role == .user })?.content ?? "" - } - - // MARK: - Error mapping - - private func availabilityError() -> Error { - switch SystemLanguageModel.default.availability { - case .unavailable(.deviceNotEligible): - return ProviderError.unknown("This Mac doesn't support Apple Intelligence. Apple Silicon is required.") - case .unavailable(.appleIntelligenceNotEnabled): - return ProviderError.unknown("Apple Intelligence is not enabled. Open System Settings → Apple Intelligence to turn it on.") - case .unavailable(.modelNotReady): - return ProviderError.unknown("Apple Intelligence model is still downloading. Please wait and try again.") - default: - return ProviderError.unknown("Apple Intelligence is not available on this device.") - } - } - - private func mapGenerationError(_ error: LanguageModelSession.GenerationError) -> Error { - switch error { - case .exceededContextWindowSize: - return ProviderError.unknown("Apple Intelligence context limit exceeded (4,096 tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.") - case .rateLimited: - return ProviderError.rateLimitExceeded - case .guardrailViolation: - return ProviderError.unknown("Apple Intelligence declined to respond to this message.") - default: - return error - } - } -} diff --git a/oAI/Providers/ProviderRegistry.swift b/oAI/Providers/ProviderRegistry.swift index e0d3aa6..9785a62 100644 --- a/oAI/Providers/ProviderRegistry.swift +++ b/oAI/Providers/ProviderRegistry.swift @@ -69,9 +69,6 @@ class ProviderRegistry { case .ollama: provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL) - - case .appleOnDevice: - provider = AppleFoundationProvider() } // Cache and return @@ -109,8 +106,6 @@ class ProviderRegistry { return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty case .ollama: return settings.ollamaConfigured - case .appleOnDevice: - return true // no API key needed } } diff --git a/oAI/Utilities/Extensions/Color+Extensions.swift b/oAI/Utilities/Extensions/Color+Extensions.swift index 6c790a1..e36d750 100644 --- a/oAI/Utilities/Extensions/Color+Extensions.swift +++ b/oAI/Utilities/Extensions/Color+Extensions.swift @@ -60,11 +60,10 @@ extension Color { static func providerColor(_ provider: Settings.Provider) -> Color { switch provider { - case .openrouter: return Color(hex: "#7c3aed") // Purple - case .anthropic: return Color(hex: "#d4895a") // Orange - case .openai: return Color(hex: "#10a37f") // Green - case .ollama: return Color(hex: "#ffffff") // White - case .appleOnDevice: return Color(hex: "#636366") // Apple grey + case .openrouter: return Color(hex: "#7c3aed") // Purple + case .anthropic: return Color(hex: "#d4895a") // Orange + case .openai: return Color(hex: "#10a37f") // Green + case .ollama: return Color(hex: "#ffffff") // White } } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 31695ba..d398f93 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -424,8 +424,6 @@ Don't narrate future actions ("Let me...") - just use the tools. func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) } private func inferProvider(from modelId: String) -> Settings.Provider? { - // Apple Foundation Models - if modelId.hasPrefix("apple-") { return .appleOnDevice } // OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet") if modelId.contains("/") { return .openrouter } // Anthropic direct (e.g. "claude-sonnet-4-5-20250929") diff --git a/oAI/Views/Screens/CreditsView.swift b/oAI/Views/Screens/CreditsView.swift index a3a2298..b60bdd4 100644 --- a/oAI/Views/Screens/CreditsView.swift +++ b/oAI/Views/Screens/CreditsView.swift @@ -80,17 +80,6 @@ struct CreditsView: View { .font(.system(size: 40)) .foregroundColor(.green) .padding(.top) - - case .appleOnDevice: - Text("Apple Intelligence") - .font(.headline) - Text("On-device and free — no credits or API key needed.") - .font(.body) - .foregroundColor(.secondary) - Image(systemName: "apple.logo") - .font(.system(size: 40)) - .foregroundColor(.secondary) - .padding(.top) } } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 67d4c72..aed4728 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -25,7 +25,6 @@ import SwiftUI import UniformTypeIdentifiers -import FoundationModels struct SettingsView: View { @Environment(\.dismiss) var dismiss @@ -307,29 +306,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } - // Apple Intelligence - VStack(alignment: .leading, spacing: 6) { - sectionHeader("Apple Intelligence") - formSection { - row("Status") { - appleIntelligenceStatusBadge - } - rowDivider() - row("Model") { - Text("On-Device (4K context)") - .foregroundStyle(.secondary) - } - rowDivider() - row("") { - Button("Open Apple Intelligence Settings") { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.aisettings") { - NSWorkspace.shared.open(url) - } - } - } - } - } - // Features VStack(alignment: .leading, spacing: 6) { sectionHeader("Features") @@ -2673,28 +2649,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak formatter.unitsStyle = .full return formatter.localizedString(for: date, relativeTo: .now) } - - @ViewBuilder - private var appleIntelligenceStatusBadge: some View { - let availability = SystemLanguageModel.default.availability - switch availability { - case .available: - Label("Available", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - case .unavailable(.deviceNotEligible): - Label("Not supported on this Mac", systemImage: "xmark.circle.fill") - .foregroundStyle(.red) - case .unavailable(.appleIntelligenceNotEnabled): - Label("Not enabled — open Apple Intelligence Settings", systemImage: "exclamationmark.circle.fill") - .foregroundStyle(.orange) - case .unavailable(.modelNotReady): - Label("Model downloading…", systemImage: "arrow.down.circle.fill") - .foregroundStyle(.orange) - default: - Label("Unavailable", systemImage: "questionmark.circle.fill") - .foregroundStyle(.secondary) - } - } } #Preview { From ef1c05c13b7495d7dae5f5fddf85c609de184d13 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 16 Jun 2026 11:56:05 +0200 Subject: [PATCH 04/14] Add Claude Fable 5 pricing ($10/$50 per 1M tokens) Co-Authored-By: Claude Sonnet 4.6 --- .../xcshareddata/xcschemes/oAI.xcscheme | 79 ------------------- oAI/Providers/AnthropicProvider.swift | 10 +++ 2 files changed, 10 insertions(+), 79 deletions(-) delete mode 100644 oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme diff --git a/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme b/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme deleted file mode 100644 index 2336f58..0000000 --- a/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/oAI/Providers/AnthropicProvider.swift b/oAI/Providers/AnthropicProvider.swift index 8e17e8f..9ed37e4 100644 --- a/oAI/Providers/AnthropicProvider.swift +++ b/oAI/Providers/AnthropicProvider.swift @@ -77,6 +77,15 @@ class AnthropicProvider: AIProvider { /// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301") /// still inherit the correct pricing tier. private static let knownModels: [ModelInfo] = [ + // Claude Fable 5 + ModelInfo( + id: "claude-fable-5", + name: "Claude Fable 5", + description: "Anthropic's creative and storytelling model", + contextLength: 200_000, + pricing: .init(prompt: 10.0, completion: 50.0), + capabilities: .init(vision: true, tools: true, online: true) + ), // Claude 4.x series ModelInfo( id: "claude-opus-4-6", @@ -173,6 +182,7 @@ class AnthropicProvider: AIProvider { /// Pricing tiers used for fuzzy fallback matching on unknown model IDs. /// Keyed by model name prefix (longest match wins). private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [ + ("claude-fable", 10.0, 50.0), ("claude-opus", 15.0, 75.0), ("claude-sonnet", 3.0, 15.0), ("claude-haiku", 0.80, 4.0), From b3bb7c4a5958776f6e55f2752bfdfd96806b45bb Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 16 Jun 2026 14:39:53 +0200 Subject: [PATCH 05/14] Fix Enter key semantics and add expandable model descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace TextEditor with NativeTextEditor (NSViewRepresentable) so plain Enter sends the message and Shift/Cmd+Enter inserts a newline. The old TextEditor passed bare Return directly to NSTextView before SwiftUI's onKeyPress could intercept it, accidentally making Cmd+Enter send instead. - Add More…/Less toggle in ModelInfoView for descriptions longer than 250 characters, with smooth expand/collapse animation. Co-Authored-By: Claude Sonnet 4.6 --- oAI/Views/Main/InputBar.swift | 83 ++++++------- oAI/Views/Main/NativeTextEditor.swift | 171 ++++++++++++++++++++++++++ oAI/Views/Screens/ModelInfoView.swift | 13 +- 3 files changed, 222 insertions(+), 45 deletions(-) create mode 100644 oAI/Views/Main/NativeTextEditor.swift diff --git a/oAI/Views/Main/InputBar.swift b/oAI/Views/Main/InputBar.swift index 9985223..1c7fe42 100644 --- a/oAI/Views/Main/InputBar.swift +++ b/oAI/Views/Main/InputBar.swift @@ -44,7 +44,7 @@ struct InputBar: View { @State private var showCommandDropdown = false @State private var selectedSuggestionIndex: Int = 0 - @FocusState private var isInputFocused: Bool + @State private var isInputFocused: Bool = false private static let minInputHeight: CGFloat = 56 private static let maxInputHeight: CGFloat = 320 @@ -95,55 +95,50 @@ struct InputBar: View { } // Editor — fills the fixed-height box, bottom area reserved for globe - TextEditor(text: $text) - .font(.system(size: settings.inputTextSize)) - .foregroundColor(.oaiPrimary) - .scrollContentBackground(.hidden) - .padding(.horizontal, 8) - .padding(.top, 6) - .padding(.bottom, 30) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .focused($isInputFocused) - .onChange(of: text) { - showCommandDropdown = text.hasPrefix("/") - selectedSuggestionIndex = 0 - } - #if os(macOS) - .onKeyPress(.upArrow) { - if showCommandDropdown && selectedSuggestionIndex > 0 { - selectedSuggestionIndex -= 1 - return .handled - } - return .ignored - } - .onKeyPress(.downArrow) { - if showCommandDropdown { - let count = CommandSuggestionsView.filteredCommands(for: text).count - if selectedSuggestionIndex < count - 1 { - selectedSuggestionIndex += 1 - return .handled - } - } - return .ignored - } - .onKeyPress(.escape) { - if showCommandDropdown { showCommandDropdown = false; return .handled } - if isGenerating { onCancel(); return .handled } - return .ignored - } - .onKeyPress(.return, phases: .down) { press in - if press.modifiers.contains(.shift) { return .ignored } + NativeTextEditor( + text: $text, + font: .systemFont(ofSize: settings.inputTextSize), + textColor: NSColor(Color.oaiPrimary), + isFocused: isInputFocused, + onReturn: { if showCommandDropdown { let suggestions = CommandSuggestionsView.filteredCommands(for: text) if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count { selectCommand(suggestions[selectedSuggestionIndex].command) - return .handled + return true } } - if !text.isEmpty { onSend(); return .handled } - return .handled - } - #endif + if !text.isEmpty { onSend(); return true } + return true + }, + onEscape: { + if showCommandDropdown { showCommandDropdown = false; return true } + if isGenerating { onCancel(); return true } + return false + }, + onUpArrow: { + if showCommandDropdown && selectedSuggestionIndex > 0 { + selectedSuggestionIndex -= 1; return true + } + return false + }, + onDownArrow: { + if showCommandDropdown { + let count = CommandSuggestionsView.filteredCommands(for: text).count + if selectedSuggestionIndex < count - 1 { + selectedSuggestionIndex += 1; return true + } + } + return false + }, + onFocusChange: { focused in isInputFocused = focused } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: text) { + showCommandDropdown = text.hasPrefix("/") + selectedSuggestionIndex = 0 + } + .padding(.bottom, 30) // Online / offline toggle — bottom-left of the text box VStack { diff --git a/oAI/Views/Main/NativeTextEditor.swift b/oAI/Views/Main/NativeTextEditor.swift new file mode 100644 index 0000000..9902925 --- /dev/null +++ b/oAI/Views/Main/NativeTextEditor.swift @@ -0,0 +1,171 @@ +// +// NativeTextEditor.swift +// oAI +// +// NSViewRepresentable text editor with correct Enter-key semantics: +// plain Enter → send, Shift+Enter or Cmd+Enter → newline. +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Rune Olsen + +import SwiftUI +import AppKit + +struct NativeTextEditor: NSViewRepresentable { + @Binding var text: String + var font: NSFont + var textColor: NSColor + var isFocused: Bool + + /// Plain Enter (no modifiers). Return true if the event was consumed. + var onReturn: () -> Bool + /// Escape key. Return true if consumed. + var onEscape: () -> Bool + /// Up arrow. Return true if consumed. + var onUpArrow: () -> Bool + /// Down arrow. Return true if consumed. + var onDownArrow: () -> Bool + /// Called when the view gains or loses first-responder status. + var onFocusChange: (Bool) -> Void + + // MARK: - NSViewRepresentable + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + + let tv = context.coordinator.textView + tv.delegate = context.coordinator + tv.isEditable = true + tv.isRichText = false + tv.drawsBackground = false + tv.backgroundColor = .clear + tv.isAutomaticQuoteSubstitutionEnabled = false + tv.isAutomaticDashSubstitutionEnabled = false + tv.isAutomaticSpellingCorrectionEnabled = true + tv.isContinuousSpellCheckingEnabled = true + tv.allowsUndo = true + tv.isVerticallyResizable = true + tv.isHorizontallyResizable = false + tv.autoresizingMask = [.width] + tv.textContainer?.widthTracksTextView = true + tv.textContainerInset = NSSize(width: 8, height: 6) + + scrollView.documentView = tv + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + let tv = context.coordinator.textView + let coord = context.coordinator + + // Update text only when it differs (avoids caret-jumping on every keystroke) + if tv.string != text { + let sel = tv.selectedRanges + tv.string = text + let len = (tv.string as NSString).length + tv.selectedRanges = sel.map { v in + let r = v.rangeValue + let loc = min(r.location, len) + let length = min(r.length, max(0, len - loc)) + return NSValue(range: NSRange(location: loc, length: length)) + } + } + + if tv.font != font { tv.font = font } + if tv.textColor != textColor { tv.textColor = textColor } + + // Keep coordinator callbacks current with each SwiftUI render + coord.textBinding = $text + coord.onReturn = onReturn + coord.onEscape = onEscape + coord.onUpArrow = onUpArrow + coord.onDownArrow = onDownArrow + coord.onFocusChange = onFocusChange + + if isFocused { + DispatchQueue.main.async { + guard let window = tv.window, window.firstResponder !== tv else { return } + window.makeFirstResponder(tv) + } + } + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + // MARK: - Coordinator + + final class Coordinator: NSObject, NSTextViewDelegate { + let textView = KeyableNSTextView() + + // Updated on every SwiftUI render via updateNSView + var textBinding: Binding? + var onReturn: () -> Bool = { false } + var onEscape: () -> Bool = { false } + var onUpArrow: () -> Bool = { false } + var onDownArrow: () -> Bool = { false } + var onFocusChange: (Bool) -> Void = { _ in } + + override init() { + super.init() + textView.coordinator = self + } + + func textDidChange(_ notification: Notification) { + guard let tv = notification.object as? NSTextView else { return } + textBinding?.wrappedValue = tv.string + } + } +} + +// MARK: - KeyableNSTextView + +/// NSTextView that routes Return / Escape / arrow keys to the SwiftUI +/// coordinator before the AppKit default handling runs. +final class KeyableNSTextView: NSTextView { + weak var coordinator: NativeTextEditor.Coordinator? + + override func keyDown(with event: NSEvent) { + guard let coord = coordinator else { super.keyDown(with: event); return } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let shift = flags.contains(.shift) + let cmd = flags.contains(.command) + + switch event.keyCode { + case 36: // Return + if shift || cmd { + // Shift+Enter or Cmd+Enter → literal newline + insertNewlineIgnoringFieldEditor(nil) + } else { + // Plain Enter → let SwiftUI decide (send or select dropdown item) + if !coord.onReturn() { + insertNewlineIgnoringFieldEditor(nil) + } + } + case 53: // Escape + if !coord.onEscape() { super.keyDown(with: event) } + case 126: // Up arrow + if !coord.onUpArrow() { super.keyDown(with: event) } + case 125: // Down arrow + if !coord.onDownArrow() { super.keyDown(with: event) } + default: + super.keyDown(with: event) + } + } + + override func becomeFirstResponder() -> Bool { + let ok = super.becomeFirstResponder() + if ok { coordinator?.onFocusChange(true) } + return ok + } + + override func resignFirstResponder() -> Bool { + let ok = super.resignFirstResponder() + if ok { coordinator?.onFocusChange(false) } + return ok + } +} diff --git a/oAI/Views/Screens/ModelInfoView.swift b/oAI/Views/Screens/ModelInfoView.swift index e785c76..aabce4a 100644 --- a/oAI/Views/Screens/ModelInfoView.swift +++ b/oAI/Views/Screens/ModelInfoView.swift @@ -30,6 +30,7 @@ struct ModelInfoView: View { @Environment(\.dismiss) var dismiss @Bindable private var settings = SettingsService.shared + @State private var isDescriptionExpanded = false var body: some View { VStack(spacing: 0) { @@ -78,8 +79,18 @@ struct ModelInfoView: View { Text(desc) .font(.body) .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(isDescriptionExpanded ? nil : 4) .textSelection(.enabled) + if desc.count > 250 { + Button(isDescriptionExpanded ? "Less" : "More…") { + withAnimation(.easeInOut(duration: 0.2)) { + isDescriptionExpanded.toggle() + } + } + .font(.callout) + .foregroundStyle(.blue) + .buttonStyle(.plain) + } } .padding(.leading, 4) } From 22f745762f0731bed4b16cabafefc6e177201170 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 16 Jun 2026 14:44:32 +0200 Subject: [PATCH 06/14] Move conversation name to header (macOS document-title style) The save indicator was sitting in the bottom-right corner of the footer. Moved it to the center of the header bar, where macOS apps conventionally show the document/conversation title. An orange dot appears when there are unsaved changes; clicking saves. Removed the indicator from the footer. Co-Authored-By: Claude Sonnet 4.6 --- oAI/Views/Main/ChatView.swift | 5 +- oAI/Views/Main/FooterView.swift | 9 -- oAI/Views/Main/HeaderView.swift | 222 ++++++++++++++++++-------------- 3 files changed, 129 insertions(+), 107 deletions(-) diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift index 74c6943..48b01b7 100644 --- a/oAI/Views/Main/ChatView.swift +++ b/oAI/Views/Main/ChatView.swift @@ -38,7 +38,10 @@ struct ChatView: View { provider: viewModel.currentProvider, model: viewModel.selectedModel, onModelSelect: onModelSelect, - onProviderChange: onProviderChange + onProviderChange: onProviderChange, + conversationName: viewModel.currentConversationName, + hasUnsavedChanges: viewModel.hasUnsavedChanges, + onQuickSave: viewModel.quickSave ) // Messages diff --git a/oAI/Views/Main/FooterView.swift b/oAI/Views/Main/FooterView.swift index 178be1d..85085d7 100644 --- a/oAI/Views/Main/FooterView.swift +++ b/oAI/Views/Main/FooterView.swift @@ -93,15 +93,6 @@ struct FooterView: View { } #endif - // Save indicator (only when chat has messages) - if stats.messageCount > 0 { - SaveIndicator( - conversationName: conversationName, - hasUnsavedChanges: hasUnsavedChanges, - onSave: onQuickSave - ) - } - // Update available badge (shows only when an update exists — no version number) #if os(macOS) UpdateBadge() diff --git a/oAI/Views/Main/HeaderView.swift b/oAI/Views/Main/HeaderView.swift index 4280a9d..b2b8087 100644 --- a/oAI/Views/Main/HeaderView.swift +++ b/oAI/Views/Main/HeaderView.swift @@ -31,109 +31,24 @@ struct HeaderView: View { let model: ModelInfo? let onModelSelect: () -> Void let onProviderChange: (Settings.Provider) -> Void + var conversationName: String? = nil + var hasUnsavedChanges: Bool = false + var onQuickSave: (() -> Void)? = nil private let settings = SettingsService.shared private let registry = ProviderRegistry.shared var body: some View { - HStack(spacing: 12) { - // Provider picker dropdown — only shows configured providers - Menu { - ForEach(registry.configuredProviders, id: \.self) { p in - Button { - onProviderChange(p) - } label: { - HStack { - Image(systemName: p.iconName) - Text(p.displayName) - if p == provider { - Image(systemName: "checkmark") - } - } - } - } - } label: { - HStack(spacing: 4) { - Image(systemName: provider.iconName) - .font(.system(size: settings.guiTextSize - 2)) - Text(provider.displayName) - .font(.system(size: settings.guiTextSize - 2, weight: .medium)) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 8)) - .opacity(0.7) - } - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.providerColor(provider)) - .cornerRadius(4) - } - .menuStyle(.borderlessButton) - .fixedSize() - .help("Switch provider") - - // Model name (clickable → model selector) - Button(action: onModelSelect) { - if let model = model { - HStack(spacing: 6) { - Text(model.name) - .font(.system(size: settings.guiTextSize, weight: .medium)) - .foregroundColor(.oaiPrimary) - - // Capability badges - HStack(spacing: 3) { - if model.capabilities.vision { - Image(systemName: "eye") - .font(.system(size: 9)) - .foregroundColor(.oaiSecondary) - } - if model.capabilities.tools { - Image(systemName: "wrench") - .font(.system(size: 9)) - .foregroundColor(.oaiSecondary) - } - if model.capabilities.online { - Image(systemName: "globe") - .font(.system(size: 9)) - .foregroundColor(.oaiSecondary) - } - if model.capabilities.imageGeneration { - Image(systemName: "paintbrush") - .font(.system(size: 9)) - .foregroundColor(.oaiSecondary) - } - } - - Image(systemName: "chevron.down") - .font(.caption2) - .foregroundColor(.oaiSecondary) - } - } else { - HStack(spacing: 4) { - Text("No model selected") - .font(.system(size: settings.guiTextSize)) - .foregroundColor(.oaiSecondary) - Image(systemName: "chevron.down") - .font(.caption2) - .foregroundColor(.oaiSecondary) - } - } - } - .buttonStyle(.plain) - .help("Select model") - - // Favourite star - if let model = model { - let isFav = settings.favoriteModelIds.contains(model.id) - Button(action: { settings.toggleFavoriteModel(model.id) }) { - Image(systemName: isFav ? "star.fill" : "star") - .font(.system(size: settings.guiTextSize - 3)) - .foregroundColor(isFav ? .yellow : .oaiSecondary) - } - .buttonStyle(.plain) - .help(isFav ? "Remove from favorites" : "Add to favorites") + ZStack { + // Left: provider + model + star + HStack(spacing: 12) { + providerMenu + modelButton + starButton + Spacer() } - Spacer() + // Center: conversation title (macOS document-title style) + conversationTitle } .padding(.horizontal, 16) .padding(.vertical, 8) @@ -145,6 +60,119 @@ struct HeaderView: View { alignment: .bottom ) } + + // MARK: - Conversation title (center) + + @ViewBuilder + private var conversationTitle: some View { + if let name = conversationName { + Button(action: { if hasUnsavedChanges { onQuickSave?() } }) { + HStack(spacing: 5) { + if hasUnsavedChanges { + Circle() + .fill(Color.orange) + .frame(width: 6, height: 6) + } + Text(name) + .font(.system(size: settings.guiTextSize - 1, weight: .medium)) + .foregroundColor(.oaiPrimary) + .lineLimit(1) + .frame(maxWidth: 300) + } + } + .buttonStyle(.plain) + .disabled(!hasUnsavedChanges) + .help(hasUnsavedChanges ? "Unsaved changes — click to save" : "Saved") + .animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges) + .animation(.easeInOut(duration: 0.2), value: name) + } + } + + // MARK: - Subviews (extracted so ZStack stays readable) + + private var providerMenu: some View { + Menu { + ForEach(registry.configuredProviders, id: \.self) { p in + Button { + onProviderChange(p) + } label: { + HStack { + Image(systemName: p.iconName) + Text(p.displayName) + if p == provider { Image(systemName: "checkmark") } + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: provider.iconName) + .font(.system(size: settings.guiTextSize - 2)) + Text(provider.displayName) + .font(.system(size: settings.guiTextSize - 2, weight: .medium)) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8)) + .opacity(0.7) + } + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.providerColor(provider)) + .cornerRadius(4) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Switch provider") + } + + private var modelButton: some View { + Button(action: onModelSelect) { + if let model = model { + HStack(spacing: 6) { + Text(model.name) + .font(.system(size: settings.guiTextSize, weight: .medium)) + .foregroundColor(.oaiPrimary) + HStack(spacing: 3) { + if model.capabilities.vision { + Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary) + } + if model.capabilities.tools { + Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary) + } + if model.capabilities.online { + Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary) + } + if model.capabilities.imageGeneration { + Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary) + } + } + Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary) + } + } else { + HStack(spacing: 4) { + Text("No model selected") + .font(.system(size: settings.guiTextSize)) + .foregroundColor(.oaiSecondary) + Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary) + } + } + } + .buttonStyle(.plain) + .help("Select model") + } + + @ViewBuilder + private var starButton: some View { + if let model = model { + let isFav = settings.favoriteModelIds.contains(model.id) + Button(action: { settings.toggleFavoriteModel(model.id) }) { + Image(systemName: isFav ? "star.fill" : "star") + .font(.system(size: settings.guiTextSize - 3)) + .foregroundColor(isFav ? .yellow : .oaiSecondary) + } + .buttonStyle(.plain) + .help(isFav ? "Remove from favorites" : "Add to favorites") + } + } } // MARK: - Status Pills (used by SidebarView) From 92e393ab0389ace9e8154d965f2873ec9ef2a2dd Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 16 Jun 2026 14:59:07 +0200 Subject: [PATCH 07/14] Fix Swift 6 actor-isolation warnings in model inits and services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Message, Conversation, EmailLog: add nonisolated to inits — plain value types have no actor isolation, but the macOS 27 SDK was inferring it - EncryptionService: replace lazy var encryptionKey (which mutates self and gets inferred as @MainActor) with an eagerly-initialized let in init() - FileLogger: add nonisolated to shared, write, and minimumLevel so they are callable from nonisolated AppLogger methods without warnings - LogLevel.<: add nonisolated to the Comparable conformance method Co-Authored-By: Claude Sonnet 4.6 --- oAI/Models/Conversation.swift | 2 +- oAI/Models/EmailLog.swift | 2 +- oAI/Models/Message.swift | 2 +- oAI/Services/EncryptionService.swift | 17 +++++++---------- oAI/Utilities/Logging.swift | 10 +++++----- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/oAI/Models/Conversation.swift b/oAI/Models/Conversation.swift index bd33df7..6a1bb3d 100644 --- a/oAI/Models/Conversation.swift +++ b/oAI/Models/Conversation.swift @@ -33,7 +33,7 @@ struct Conversation: Identifiable, Codable { var updatedAt: Date var primaryModel: String? // Primary model used in this conversation - init( + nonisolated init( id: UUID = UUID(), name: String, messages: [Message] = [], diff --git a/oAI/Models/EmailLog.swift b/oAI/Models/EmailLog.swift index b2af49d..75ade66 100644 --- a/oAI/Models/EmailLog.swift +++ b/oAI/Models/EmailLog.swift @@ -44,7 +44,7 @@ struct EmailLog: Identifiable, Codable, Equatable { let responseTime: TimeInterval? // Time to generate response in seconds let modelId: String? // Model that handled the email - init( + nonisolated init( id: UUID = UUID(), timestamp: Date = Date(), sender: String, diff --git a/oAI/Models/Message.swift b/oAI/Models/Message.swift index 56c2e27..8914cf2 100644 --- a/oAI/Models/Message.swift +++ b/oAI/Models/Message.swift @@ -66,7 +66,7 @@ struct Message: Identifiable, Codable, Equatable { // Reasoning/thinking content (not persisted — in-memory only) var thinkingContent: String? = nil - init( + nonisolated init( id: UUID = UUID(), role: MessageRole, content: String, diff --git a/oAI/Services/EncryptionService.swift b/oAI/Services/EncryptionService.swift index 4ea0864..70711a4 100644 --- a/oAI/Services/EncryptionService.swift +++ b/oAI/Services/EncryptionService.swift @@ -31,12 +31,11 @@ import IOKit class EncryptionService { nonisolated static let shared = EncryptionService() - private let salt = "oAI-secure-storage-v1" // App-specific salt - private lazy var encryptionKey: SymmetricKey = { - deriveEncryptionKey() - }() + private let encryptionKey: SymmetricKey - private init() {} + private init() { + self.encryptionKey = Self.deriveEncryptionKey() + } // MARK: - Public Interface @@ -73,19 +72,17 @@ class EncryptionService { // MARK: - Key Derivation /// Derive encryption key from machine-specific data - private func deriveEncryptionKey() -> SymmetricKey { - // Combine machine UUID + bundle ID + salt for key material + private static func deriveEncryptionKey() -> SymmetricKey { let machineUUID = getMachineUUID() let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI" + let salt = "oAI-secure-storage-v1" let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)" - - // Hash to create consistent 256-bit key let hash = SHA256.hash(data: Data(keyMaterial.utf8)) return SymmetricKey(data: hash) } /// Get machine-specific UUID (IOPlatformUUID) - private func getMachineUUID() -> String { + private static func getMachineUUID() -> String { // Get IOPlatformUUID from IOKit let platformExpert = IOServiceGetMatchingService( kIOMainPortDefault, diff --git a/oAI/Utilities/Logging.swift b/oAI/Utilities/Logging.swift index 483fd92..93dac72 100644 --- a/oAI/Utilities/Logging.swift +++ b/oAI/Utilities/Logging.swift @@ -52,7 +52,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable { } } - static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + nonisolated static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rawValue < rhs.rawValue } } @@ -60,7 +60,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable { // MARK: - File Logger final class FileLogger: @unchecked Sendable { - static let shared = FileLogger() + nonisolated static let shared = FileLogger() private let fileHandle: FileHandle? private let queue = DispatchQueue(label: "com.oai.filelogger") @@ -70,8 +70,8 @@ final class FileLogger: @unchecked Sendable { return f }() - /// Current minimum log level (read from UserDefaults for thread safety) - var minimumLevel: LogLevel { + /// Current minimum log level (backed by UserDefaults — thread-safe). + nonisolated var minimumLevel: LogLevel { get { let raw = UserDefaults.standard.integer(forKey: "logLevel") return LogLevel(rawValue: raw) ?? .info @@ -95,7 +95,7 @@ final class FileLogger: @unchecked Sendable { fileHandle?.seekToEndOfFile() } - func write(_ level: LogLevel, category: String, message: String) { + nonisolated func write(_ level: LogLevel, category: String, message: String) { guard level >= minimumLevel else { return } queue.async { [weak self] in guard let self, let fh = self.fileHandle else { return } From 00dccd648cf7ddc25845c8552937a7fca482dce6 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 17 Jun 2026 11:00:55 +0200 Subject: [PATCH 08/14] README.md edits --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index eeb6309..cc9ca76 100644 --- a/README.md +++ b/README.md @@ -332,9 +332,6 @@ This means you are free to use, study, modify, and distribute oAI, but any modif See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html). -## Development - -See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, database schema, and contribution guidelines. ## Author @@ -344,9 +341,6 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, datab - Blog: [https://blog.rune.pm](https://blog.rune.pm) - Gitlab.pm: [@rune](https://gitlab.pm/rune) -## Contributing - -Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instructions and project structure. --- From 3dff8a8c8e086c300e03683319114611c56c6bf4 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 17 Jun 2026 11:45:56 +0200 Subject: [PATCH 09/14] Add combine saved conversations feature (simple + AI-assisted merge) Lets users multi-select 2+ saved conversations and merge them into one, either by chronological concatenation or by having the default model synthesize a single coherent conversation from the source transcripts. Co-Authored-By: Claude Sonnet 4.6 --- oAI/Services/ConversationMergeService.swift | 178 ++++++++++++++++ .../Screens/CombineConversationsSheet.swift | 192 ++++++++++++++++++ oAI/Views/Screens/ConversationListView.swift | 23 +++ 3 files changed, 393 insertions(+) create mode 100644 oAI/Services/ConversationMergeService.swift create mode 100644 oAI/Views/Screens/CombineConversationsSheet.swift diff --git a/oAI/Services/ConversationMergeService.swift b/oAI/Services/ConversationMergeService.swift new file mode 100644 index 0000000..be26b29 --- /dev/null +++ b/oAI/Services/ConversationMergeService.swift @@ -0,0 +1,178 @@ +// +// ConversationMergeService.swift +// oAI +// +// Combine multiple saved conversations into one (simple concatenation or AI-assisted merge) +// +// 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 . + + +import Foundation +import os + +enum CombineMode: String, Sendable { + case simple + case ai +} + +enum MergeError: LocalizedError { + case tooFewConversations + case noDefaultModel + case noAPIKey + case invalidAIResponse(String) + + var errorDescription: String? { + switch self { + case .tooFewConversations: + return "Select at least two conversations to combine." + case .noDefaultModel: + return "No default model is configured. Set one in Settings → General → Default Model." + case .noAPIKey: + return "No API key configured for the default provider. Add one in Settings." + case .invalidAIResponse(let snippet): + return "The model's response could not be parsed into a conversation: \(snippet)" + } + } +} + +enum ConversationMergeService { + + static func merge( + conversationIds: [UUID], + name: String, + mode: CombineMode, + deleteOriginals: Bool + ) async throws -> Conversation { + guard conversationIds.count >= 2 else { + throw MergeError.tooFewConversations + } + + let sources: [(Conversation, [Message])] = try conversationIds.compactMap { id in + try DatabaseService.shared.loadConversation(id: id) + } + + let mergedMessages: [Message] + switch mode { + case .simple: + mergedMessages = simpleMerge(sources) + case .ai: + mergedMessages = try await aiMerge(sources) + } + + let newConversation = try DatabaseService.shared.saveConversation(name: name, messages: mergedMessages) + + if deleteOriginals { + for id in conversationIds { + _ = try? DatabaseService.shared.deleteConversation(id: id) + } + } + + Log.db.info("Combined \(conversationIds.count) conversations into '\(name)' (mode: \(mode.rawValue), deleteOriginals: \(deleteOriginals))") + + return newConversation + } + + private static func simpleMerge(_ sources: [(Conversation, [Message])]) -> [Message] { + sources.flatMap { $0.1 }.sorted { $0.timestamp < $1.timestamp } + } + + private struct MergedTurn: Codable { + let role: String + let content: String + } + + private static func aiMerge(_ sources: [(Conversation, [Message])]) async throws -> [Message] { + let settings = SettingsService.shared + guard let modelId = settings.defaultModel, !modelId.isEmpty else { + throw MergeError.noDefaultModel + } + guard let provider = ProviderRegistry.shared.getProvider(for: settings.defaultProvider) else { + throw MergeError.noAPIKey + } + + let transcript = sources.map { conversation, messages -> String in + let body = messages.map { msg -> String in + let label = msg.role == .user ? "**User:**" : "**Assistant:**" + return "\(label) \(msg.content)" + }.joined(separator: "\n\n") + return "### Conversation: \(conversation.name)\n\n\(body)" + }.joined(separator: "\n\n---\n\n") + + let mergePrompt = """ + Merge the following saved conversation transcripts into a single, coherent conversation. \ + Remove redundant or duplicate exchanges, keep the most informative answer when sources overlap, \ + preserve important details from each source, and do not invent facts that were not in the originals. + + Respond with ONLY a JSON array of message objects in logical order, each in the form \ + {"role": "user" or "assistant", "content": "..."}. Do not include any text outside the JSON array. + + \(transcript) + """ + + let request = ChatRequest( + messages: [Message(role: .user, content: mergePrompt)], + model: modelId, + stream: false, + maxTokens: 4000, + temperature: 0.3, + topP: nil, + systemPrompt: "You are a helpful assistant that merges chat conversation transcripts into one clean, coherent conversation.", + tools: nil, + onlineMode: false, + imageGeneration: false + ) + + let response: ChatResponse + do { + response = try await provider.chat(request: request) + } catch { + Log.api.error("Conversation merge AI call failed: \(error.localizedDescription)") + throw error + } + + let turns = try parseTurns(from: response.content) + + let base = Date() + return turns.enumerated().map { index, turn in + Message( + role: turn.role == "user" ? .user : .assistant, + content: turn.content, + timestamp: base.addingTimeInterval(TimeInterval(index)), + modelId: modelId + ) + } + } + + private static func parseTurns(from raw: String) throws -> [MergedTurn] { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.hasPrefix("```") { + text = text.components(separatedBy: "\n").dropFirst().joined(separator: "\n") + if text.hasSuffix("```") { + text = String(text.dropLast(3)) + } + text = text.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard let data = text.data(using: .utf8), + let turns = try? JSONDecoder().decode([MergedTurn].self, from: data), + !turns.isEmpty else { + throw MergeError.invalidAIResponse(String(raw.prefix(200))) + } + return turns + } +} diff --git a/oAI/Views/Screens/CombineConversationsSheet.swift b/oAI/Views/Screens/CombineConversationsSheet.swift new file mode 100644 index 0000000..03a0857 --- /dev/null +++ b/oAI/Views/Screens/CombineConversationsSheet.swift @@ -0,0 +1,192 @@ +// +// CombineConversationsSheet.swift +// oAI +// +// Combine 2+ saved conversations into one, optionally using AI to merge content +// +// 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 . + + +import SwiftUI + +struct CombineConversationsSheet: View { + @Environment(\.dismiss) var dismiss + + let conversations: [Conversation] + var onCompleted: (Conversation) -> Void + + @State private var name: String + @State private var mode: CombineMode = .simple + @State private var deleteOriginals = false + @State private var isProcessing = false + @State private var errorMessage: String? + + private let settings = SettingsService.shared + + init(conversations: [Conversation], onCompleted: @escaping (Conversation) -> Void) { + self.conversations = conversations + self.onCompleted = onCompleted + let joined = conversations.map(\.name).joined(separator: " + ") + _name = State(initialValue: String(joined.prefix(80))) + } + + private var defaultModelLabel: String? { + guard let model = settings.defaultModel, !model.isEmpty else { return nil } + return "\(settings.defaultProvider.displayName) / \(model)" + } + + private var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty + && conversations.count >= 2 + && (mode == .simple || defaultModelLabel != nil) + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Combine Conversations") + .font(.system(size: 18, weight: .bold)) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2).foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .keyboardShortcut(.escape, modifiers: []) + .disabled(isProcessing) + } + .padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Combining \(conversations.count) conversations").font(.system(size: 13, weight: .semibold)) + VStack(alignment: .leading, spacing: 4) { + ForEach(conversations) { conversation in + Label("\(conversation.name) (\(conversation.messageCount) messages)", systemImage: "bubble.left.and.bubble.right") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("New conversation name").font(.system(size: 13, weight: .semibold)) + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + .disabled(isProcessing) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Merge method").font(.system(size: 13, weight: .semibold)) + Picker("", selection: $mode) { + Text("Simple Merge").tag(CombineMode.simple) + Text("AI-Assisted Merge").tag(CombineMode.ai) + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled(isProcessing) + + if mode == .simple { + Text("Messages from all selected conversations are combined in chronological order.") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("A model reads all the source messages and rewrites them into one coherent, de-duplicated conversation.") + .font(.caption).foregroundStyle(.secondary) + if let label = defaultModelLabel { + Label("Uses your default model: \(label)", systemImage: "cpu") + .font(.caption).foregroundStyle(.secondary) + } else { + Label("No default model configured — set one in Settings → General.", systemImage: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(.orange) + } + } + } + + Toggle("Delete original conversations after combining", isOn: $deleteOriginals) + .toggleStyle(.checkbox) + .disabled(isProcessing) + + if let errorMessage { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "xmark.octagon.fill").foregroundStyle(.red) + Text(errorMessage).font(.caption) + } + .padding(10) + .background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } + } + .padding(.horizontal, 24).padding(.vertical, 16) + } + + Divider() + + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.bordered) + .disabled(isProcessing) + Spacer() + if isProcessing { + ProgressView().controlSize(.small) + Text("Combining…").font(.caption).foregroundStyle(.secondary) + } + Button("Combine") { + combine() + } + .buttonStyle(.borderedProminent) + .disabled(!isValid || isProcessing) + .keyboardShortcut(.return, modifiers: [.command]) + } + .padding(.horizontal, 24).padding(.vertical, 12) + } + .frame(minWidth: 520, idealWidth: 560, minHeight: 460, idealHeight: 520) + } + + private func combine() { + isProcessing = true + errorMessage = nil + let ids = conversations.map(\.id) + let trimmedName = name.trimmingCharacters(in: .whitespaces) + let selectedMode = mode + let shouldDeleteOriginals = deleteOriginals + + Task { + do { + let newConversation = try await ConversationMergeService.merge( + conversationIds: ids, + name: trimmedName, + mode: selectedMode, + deleteOriginals: shouldDeleteOriginals + ) + await MainActor.run { + isProcessing = false + onCompleted(newConversation) + dismiss() + } + } catch { + await MainActor.run { + isProcessing = false + errorMessage = error.localizedDescription + } + } + } + } +} diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 5ba8fb2..c35707e 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -36,6 +36,7 @@ struct ConversationListView: View { @State private var semanticResults: [Conversation] = [] @State private var isSearching = false @State private var selectedIndex: Int = 0 + @State private var showCombineSheet = false @FocusState private var searchFocused: Bool private let settings = SettingsService.shared var onLoad: ((Conversation) -> Void)? @@ -70,6 +71,18 @@ struct ConversationListView: View { } .buttonStyle(.plain) + if selectedConversations.count >= 2 { + Button { + showCombineSheet = true + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.merge") + Text("Combine (\(selectedConversations.count))") + } + } + .buttonStyle(.plain) + } + if !selectedConversations.isEmpty { Button(role: .destructive) { deleteSelected() @@ -298,6 +311,16 @@ struct ConversationListView: View { searchFocused = true } .frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600) + .sheet(isPresented: $showCombineSheet) { + CombineConversationsSheet( + conversations: conversations.filter { selectedConversations.contains($0.id) }, + onCompleted: { _ in + loadConversations() + selectedConversations.removeAll() + isSelecting = false + } + ) + } } private func loadConversations() { From 87535dc2ad28de6de9001ba323a7b6186b179fff Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 17 Jun 2026 11:48:15 +0200 Subject: [PATCH 10/14] Ignore Xcode shared scheme data Auto-generated by Xcode/xcodebuild when no shared scheme exists yet; not meant to be tracked. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 449b38f..163d5e2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ ## User settings xcuserdata/ +xcshareddata/ ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) *.xcscmblueprint From e7c7b9b5c6b531f24a379f3acff4841c5939fe55 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 17 Jun 2026 11:54:28 +0200 Subject: [PATCH 11/14] Fix combined conversation's model to reflect sources, not the merge model primaryModel was being set to the model that performed the merge (or, in AI mode, stamped onto every synthesized message). It should instead be the most recently used model among the source conversations being combined. Co-Authored-By: Claude Sonnet 4.6 --- oAI/Services/ConversationMergeService.swift | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/oAI/Services/ConversationMergeService.swift b/oAI/Services/ConversationMergeService.swift index be26b29..ffc3569 100644 --- a/oAI/Services/ConversationMergeService.swift +++ b/oAI/Services/ConversationMergeService.swift @@ -67,6 +67,14 @@ enum ConversationMergeService { try DatabaseService.shared.loadConversation(id: id) } + // The model used in the merged conversation should reflect the most recently used + // model across the *source* conversations — never the model that performed the merge. + let latestModelId = sources + .flatMap { $0.1 } + .filter { $0.modelId != nil } + .max { $0.timestamp < $1.timestamp }? + .modelId + let mergedMessages: [Message] switch mode { case .simple: @@ -75,7 +83,12 @@ enum ConversationMergeService { mergedMessages = try await aiMerge(sources) } - let newConversation = try DatabaseService.shared.saveConversation(name: name, messages: mergedMessages) + let newConversation = try DatabaseService.shared.saveConversation( + id: UUID(), + name: name, + messages: mergedMessages, + primaryModel: latestModelId + ) if deleteOriginals { for id in conversationIds { @@ -148,13 +161,15 @@ enum ConversationMergeService { let turns = try parseTurns(from: response.content) + // modelId intentionally left nil here: these messages are a synthesized composite, + // not output from a single source model. The conversation's primaryModel (set by the + // caller from the source conversations) is what drives the model shown in the list. let base = Date() return turns.enumerated().map { index, turn in Message( role: turn.role == "user" ? .user : .assistant, content: turn.content, - timestamp: base.addingTimeInterval(TimeInterval(index)), - modelId: modelId + timestamp: base.addingTimeInterval(TimeInterval(index)) ) } } From 414cf8cb8c42e141aff8c08e3b7cd770174f2da9 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 17 Jun 2026 11:59:11 +0200 Subject: [PATCH 12/14] Add missing AccentColor asset Build settings referenced ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor but no such color set existed in Assets.xcassets, causing a build warning. Added it using the app's existing blue accent (#0a7aca, same as Color.oaiAccent) for consistency. Co-Authored-By: Claude Sonnet 4.6 --- .../AccentColor.colorset/Contents.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 oAI/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/oAI/Assets.xcassets/AccentColor.colorset/Contents.json b/oAI/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..12169ab --- /dev/null +++ b/oAI/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCA", + "green" : "0x7A", + "red" : "0x0A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From a793fdacc4274d802a992f782ef698b45da29f54 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 18 Jun 2026 11:29:34 +0200 Subject: [PATCH 13/14] Changes... --- DEVELOPMENT.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 522ac2d..64ac5cb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -72,17 +72,6 @@ oAI/ ## Building -### Build Scripts - -| Script | Architecture | Output | -|--------|-------------|--------| -| `build.sh` | Apple Silicon (arm64) | Installs directly to `/Applications` | -| `build-dmg.sh` | Apple Silicon (arm64) | `oAI--AppleSilicon.dmg` on Desktop | -| `build-dmg-universal.sh` | Universal (arm64 + x86_64) | `oAI--Universal.dmg` on Desktop | -| `build_nb/sv/da/de/en.sh` | Apple Silicon (arm64) | Build + launch in specific language | - -All scripts: find Developer ID cert, clean-build via `xcodebuild`, re-sign with `codesign --options runtime --timestamp`, verify. Version is read from `MARKETING_VERSION` in `project.pbxproj`. - ### Manual Build Commands ```bash From 5b99a6f81c0f63bf542cca2fd23a1b3af060c333 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 18 Jun 2026 12:43:32 +0200 Subject: [PATCH 14/14] Add Anthropic prompt caching (direct + via OpenRouter) Caches the system prompt/tools and growing conversation history via cache_control breakpoints, cutting cost and latency on repeated turns. Covers both the regular chat path and the tool-calling loop (chatWithToolMessages), which has its own request-building code and was initially missed. Cost calculation now accounts for cache write/read pricing instead of treating all input tokens as full price. Verified live: cache reads grow turn-over-turn in oAI.log. Co-Authored-By: Claude Sonnet 4.6 --- oAI/Providers/AIProvider.swift | 12 ++++++ oAI/Providers/AnthropicProvider.swift | 59 ++++++++++++++++++++++++-- oAI/Providers/OpenRouterModels.swift | 22 +++++++++- oAI/Providers/OpenRouterProvider.swift | 32 ++++++++++++-- oAI/ViewModels/ChatViewModel.swift | 27 ++++++------ 5 files changed, 131 insertions(+), 21 deletions(-) diff --git a/oAI/Providers/AIProvider.swift b/oAI/Providers/AIProvider.swift index da6d418..10f2d00 100644 --- a/oAI/Providers/AIProvider.swift +++ b/oAI/Providers/AIProvider.swift @@ -130,11 +130,23 @@ struct ChatResponse: Codable { let promptTokens: Int let completionTokens: Int let totalTokens: Int + let cacheCreationInputTokens: Int? + let cacheReadInputTokens: Int? + + init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil) { + self.promptTokens = promptTokens + self.completionTokens = completionTokens + self.totalTokens = totalTokens + self.cacheCreationInputTokens = cacheCreationInputTokens + self.cacheReadInputTokens = cacheReadInputTokens + } enum CodingKeys: String, CodingKey { case promptTokens = "prompt_tokens" case completionTokens = "completion_tokens" case totalTokens = "total_tokens" + case cacheCreationInputTokens = "cache_creation_input_tokens" + case cacheReadInputTokens = "cache_read_input_tokens" } } diff --git a/oAI/Providers/AnthropicProvider.swift b/oAI/Providers/AnthropicProvider.swift index 9ed37e4..d801467 100644 --- a/oAI/Providers/AnthropicProvider.swift +++ b/oAI/Providers/AnthropicProvider.swift @@ -366,6 +366,19 @@ class AnthropicProvider: AIProvider { } } + // Mark the last message with a cache breakpoint so the next loop + // iteration (or next turn) can reuse everything up through this one. + if var lastMessage = conversationMessages.popLast() { + if let content = lastMessage["content"] as? String { + lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]] + } else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() { + lastBlock["cache_control"] = ["type": "ephemeral"] + blocks.append(lastBlock) + lastMessage["content"] = blocks + } + conversationMessages.append(lastMessage) + } + var body: [String: Any] = [ "model": model, "messages": conversationMessages, @@ -373,7 +386,9 @@ class AnthropicProvider: AIProvider { "stream": false ] if let systemText = systemText { - body["system"] = systemText + // Array form carries a cache breakpoint; also covers tools, which + // render before system in Anthropic's prefix order. + body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]] } if let temperature = temperature { body["temperature"] = temperature @@ -440,6 +455,8 @@ class AnthropicProvider: AIProvider { var currentId = "" var currentModel = request.model var inputTokens = 0 + var cacheCreationTokens: Int? = nil + var cacheReadTokens: Int? = nil for try await line in bytes.lines { // Anthropic SSE: "event: ..." and "data: {...}" @@ -459,6 +476,11 @@ class AnthropicProvider: AIProvider { currentModel = message["model"] as? String ?? request.model if let usageDict = message["usage"] as? [String: Any] { inputTokens = usageDict["input_tokens"] as? Int ?? 0 + cacheCreationTokens = usageDict["cache_creation_input_tokens"] as? Int + cacheReadTokens = usageDict["cache_read_input_tokens"] as? Int + if cacheCreationTokens != nil || cacheReadTokens != nil { + Log.api.info("Anthropic stream cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)") + } } } @@ -482,7 +504,13 @@ class AnthropicProvider: AIProvider { var usage: ChatResponse.Usage? = nil if let usageDict = event["usage"] as? [String: Any] { let outputTokens = usageDict["output_tokens"] as? Int ?? 0 - usage = ChatResponse.Usage(promptTokens: inputTokens, completionTokens: outputTokens, totalTokens: inputTokens + outputTokens) + usage = ChatResponse.Usage( + promptTokens: inputTokens, + completionTokens: outputTokens, + totalTokens: inputTokens + outputTokens, + cacheCreationInputTokens: cacheCreationTokens, + cacheReadInputTokens: cacheReadTokens + ) } continuation.yield(StreamChunk( id: currentId, @@ -592,6 +620,19 @@ class AnthropicProvider: AIProvider { } } + // Mark the last message with a cache breakpoint so the next turn can + // reuse everything up through this one as a cached prefix. + if var lastMessage = apiMessages.popLast() { + if let content = lastMessage["content"] as? String { + lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]] + } else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() { + lastBlock["cache_control"] = ["type": "ephemeral"] + blocks.append(lastBlock) + lastMessage["content"] = blocks + } + apiMessages.append(lastMessage) + } + var body: [String: Any] = [ "model": request.model, "messages": apiMessages, @@ -600,7 +641,10 @@ class AnthropicProvider: AIProvider { ] if let systemText = systemText { - body["system"] = systemText + // Array form (rather than a plain string) carries a cache breakpoint. + // Per Anthropic's render order (tools -> system -> messages), this + // single breakpoint caches the tool definitions too. + body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]] } if let temperature = request.temperature { body["temperature"] = temperature @@ -675,6 +719,11 @@ class AnthropicProvider: AIProvider { let usageDict = json["usage"] as? [String: Any] let inputTokens = usageDict?["input_tokens"] as? Int ?? 0 let outputTokens = usageDict?["output_tokens"] as? Int ?? 0 + let cacheCreationTokens = usageDict?["cache_creation_input_tokens"] as? Int + let cacheReadTokens = usageDict?["cache_read_input_tokens"] as? Int + if cacheCreationTokens != nil || cacheReadTokens != nil { + Log.api.info("Anthropic cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)") + } return ChatResponse( id: id, @@ -685,7 +734,9 @@ class AnthropicProvider: AIProvider { usage: ChatResponse.Usage( promptTokens: inputTokens, completionTokens: outputTokens, - totalTokens: inputTokens + outputTokens + totalTokens: inputTokens + outputTokens, + cacheCreationInputTokens: cacheCreationTokens, + cacheReadInputTokens: cacheReadTokens ), created: Date(), toolCalls: toolCalls.isEmpty ? nil : toolCalls diff --git a/oAI/Providers/OpenRouterModels.swift b/oAI/Providers/OpenRouterModels.swift index 7701a60..4fbb6b3 100644 --- a/oAI/Providers/OpenRouterModels.swift +++ b/oAI/Providers/OpenRouterModels.swift @@ -48,7 +48,12 @@ struct OpenRouterChatRequest: Codable { let toolChoice: String? let modalities: [String]? let reasoning: ReasoningAPIConfig? - + let cacheControl: CacheControl? + + struct CacheControl: Codable { + let type: String + } + struct APIMessage: Codable { let role: String let content: MessageContent @@ -138,6 +143,7 @@ struct OpenRouterChatRequest: Codable { case toolChoice = "tool_choice" case modalities case reasoning + case cacheControl = "cache_control" } } @@ -225,11 +231,23 @@ struct OpenRouterChatResponse: Codable { let promptTokens: Int let completionTokens: Int let totalTokens: Int - + let promptTokensDetails: PromptTokensDetails? + + struct PromptTokensDetails: Codable { + let cachedTokens: Int? + let cacheWriteTokens: Int? + + enum CodingKeys: String, CodingKey { + case cachedTokens = "cached_tokens" + case cacheWriteTokens = "cache_write_tokens" + } + } + enum CodingKeys: String, CodingKey { case promptTokens = "prompt_tokens" case completionTokens = "completion_tokens" case totalTokens = "total_tokens" + case promptTokensDetails = "prompt_tokens_details" } } } diff --git a/oAI/Providers/OpenRouterProvider.swift b/oAI/Providers/OpenRouterProvider.swift index 18e7fed..bcc874a 100644 --- a/oAI/Providers/OpenRouterProvider.swift +++ b/oAI/Providers/OpenRouterProvider.swift @@ -198,6 +198,11 @@ class OpenRouterProvider: AIProvider { } if let maxTokens = maxTokens { body["max_tokens"] = maxTokens } if let temperature = temperature { body["temperature"] = temperature } + // Anthropic models require an explicit cache_control opt-in on OpenRouter; + // other providers cache automatically. + if model.hasPrefix("anthropic/") { + body["cache_control"] = ["type": "ephemeral"] + } var urlRequest = URLRequest(url: url) urlRequest.httpMethod = "POST" @@ -388,6 +393,12 @@ class OpenRouterProvider: AIProvider { ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil) } + // Anthropic models require an explicit cache_control opt-in on OpenRouter; + // other providers (OpenAI, DeepSeek, Gemini, Grok, etc.) cache automatically. + let cacheControl: OpenRouterChatRequest.CacheControl? = effectiveModel.hasPrefix("anthropic/") + ? .init(type: "ephemeral") + : nil + return OpenRouterChatRequest( model: effectiveModel, messages: apiMessages, @@ -398,7 +409,8 @@ class OpenRouterProvider: AIProvider { tools: request.tools, toolChoice: request.tools != nil ? "auto" : nil, modalities: request.imageGeneration ? ["text", "image"] : nil, - reasoning: reasoningConfig + reasoning: reasoningConfig, + cacheControl: cacheControl ) } @@ -416,6 +428,11 @@ class OpenRouterProvider: AIProvider { let allImages = topLevelImages + blockImages let images: [Data]? = allImages.isEmpty ? nil : allImages + if let details = apiResponse.usage?.promptTokensDetails, + details.cachedTokens != nil || details.cacheWriteTokens != nil { + Log.api.info("OpenRouter cache usage: model=\(apiResponse.model), created=\(details.cacheWriteTokens ?? 0), read=\(details.cachedTokens ?? 0)") + } + return ChatResponse( id: apiResponse.id, model: apiResponse.model, @@ -426,7 +443,9 @@ class OpenRouterProvider: AIProvider { ChatResponse.Usage( promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, - totalTokens: usage.totalTokens + totalTokens: usage.totalTokens, + cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens, + cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens ) }, created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)), @@ -446,6 +465,11 @@ class OpenRouterProvider: AIProvider { let allImages = topLevelImages + blockImages let images: [Data]? = allImages.isEmpty ? nil : allImages + if let details = apiChunk.usage?.promptTokensDetails, + details.cachedTokens != nil || details.cacheWriteTokens != nil { + Log.api.info("OpenRouter stream cache usage: model=\(apiChunk.model), created=\(details.cacheWriteTokens ?? 0), read=\(details.cachedTokens ?? 0)") + } + return StreamChunk( id: apiChunk.id, model: apiChunk.model, @@ -460,7 +484,9 @@ class OpenRouterProvider: AIProvider { ChatResponse.Usage( promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, - totalTokens: usage.totalTokens + totalTokens: usage.totalTokens, + cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens, + cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens ) } ) diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index d398f93..e217b0b 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -934,10 +934,7 @@ Don't narrate future actions ("Let me...") - just use the tools. messages[index].tokens = usage.completionTokens if let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing - ? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + - (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) - : nil + let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) } @@ -1001,10 +998,7 @@ Don't narrate future actions ("Let me...") - just use the tools. messages[index].tokens = usage.completionTokens if let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing - ? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + - (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) - : nil + let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) } @@ -1529,10 +1523,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Calculate cost if let usage = totalUsage, let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing - ? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + - (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) - : nil + let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) { messages[index].cost = cost } @@ -2180,6 +2171,18 @@ Don't narrate future actions ("Let me...") - just use the tools. } } + /// Cost for one response's usage, accounting for Anthropic-style prompt-cache + /// pricing when present: cache writes cost 1.25x the base input rate, cache + /// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder — + /// it does not need cache tokens subtracted from it. + private func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double { + let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000 + let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000 + let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000 + let outputCost = Double(usage.completionTokens) * pricing.completion / 1_000_000 + return inputCost + cacheReadCost + cacheWriteCost + outputCost + } + /// Summarize a chunk of messages into a concise summary private func summarizeMessageChunk(_ messages: [Message]) async -> String? { guard let provider = providerRegistry.getProvider(for: currentProvider),