12 Commits

Author SHA1 Message Date
rune 5b99a6f81c 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 <noreply@anthropic.com>
2026-06-18 12:43:32 +02:00
rune a793fdacc4 Changes... 2026-06-18 11:29:34 +02:00
rune 414cf8cb8c 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 <noreply@anthropic.com>
2026-06-17 11:59:11 +02:00
rune e7c7b9b5c6 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 <noreply@anthropic.com>
2026-06-17 11:54:28 +02:00
rune 87535dc2ad 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 <noreply@anthropic.com>
2026-06-17 11:48:15 +02:00
rune 3dff8a8c8e 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 <noreply@anthropic.com>
2026-06-17 11:45:56 +02:00
rune 00dccd648c README.md edits 2026-06-17 11:00:55 +02:00
rune 92e393ab03 Fix Swift 6 actor-isolation warnings in model inits and services
- 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 <noreply@anthropic.com>
2026-06-16 14:59:07 +02:00
rune 22f745762f 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 <noreply@anthropic.com>
2026-06-16 14:44:32 +02:00
rune b3bb7c4a59 Fix Enter key semantics and add expandable model descriptions
- 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 <noreply@anthropic.com>
2026-06-16 14:39:53 +02:00
rune ef1c05c13b Add Claude Fable 5 pricing ($10/$50 per 1M tokens)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:56:05 +02:00
rune f63226b2cc Revert "Add Apple Intelligence provider (Phase 1 — on-device)"
This reverts commit f3a0c45331.
2026-06-16 11:42:51 +02:00
30 changed files with 947 additions and 566 deletions
+1
View File
@@ -4,6 +4,7 @@
## User settings
xcuserdata/
xcshareddata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
-11
View File
@@ -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-<version>-AppleSilicon.dmg` on Desktop |
| `build-dmg-universal.sh` | Universal (arm64 + x86_64) | `oAI-<version>-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
-6
View File
@@ -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.
---
@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
BuildableName = "oAI.app"
BlueprintName = "oAI"
ReferencedContainer = "container:oAI.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "en"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
BuildableName = "oAI.app"
BlueprintName = "oAI"
ReferencedContainer = "container:oAI.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
BuildableName = "oAI.app"
BlueprintName = "oAI"
ReferencedContainer = "container:oAI.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -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
}
}
+1 -1
View File
@@ -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] = [],
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -9
View File
@@ -58,16 +58,9 @@ 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 {
@@ -76,7 +69,6 @@ struct Settings: Codable {
case .anthropic: return "brain"
case .openai: return "sparkles"
case .ollama: return "server.rack"
case .appleOnDevice: return "apple.logo"
}
}
}
+12
View File
@@ -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"
}
}
+65 -4
View File
@@ -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),
@@ -356,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,
@@ -363,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
@@ -430,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: {...}"
@@ -449,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)")
}
}
}
@@ -472,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,
@@ -582,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,
@@ -590,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
@@ -665,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,
@@ -675,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
-195
View File
@@ -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 <https://www.gnu.org/licenses/>.
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<StreamChunk, Error> {
AsyncThrowingStream { continuation in
Task {
do {
let session = try self.makeSession(for: request)
let prompt = self.lastUserMessage(from: request)
// streamResponse(to: String) ResponseStream<String>
// 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<String> = 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
}
}
}
+18
View File
@@ -48,6 +48,11 @@ 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
@@ -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"
}
}
}
+29 -3
View File
@@ -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
)
}
)
-5
View File
@@ -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
}
}
+193
View File
@@ -0,0 +1,193 @@
//
// 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 <https://www.gnu.org/licenses/>.
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)
}
// 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:
mergedMessages = simpleMerge(sources)
case .ai:
mergedMessages = try await aiMerge(sources)
}
let newConversation = try DatabaseService.shared.saveConversation(
id: UUID(),
name: name,
messages: mergedMessages,
primaryModel: latestModelId
)
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)
// 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))
)
}
}
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
}
}
+7 -10
View File
@@ -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,
@@ -64,7 +64,6 @@ extension Color {
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
}
}
+5 -5
View File
@@ -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 }
+15 -14
View File
@@ -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")
@@ -936,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)
}
@@ -1003,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)
}
@@ -1531,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
}
@@ -2182,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),
+4 -1
View File
@@ -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
-9
View File
@@ -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()
+67 -39
View File
@@ -31,12 +31,66 @@ 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 {
ZStack {
// Left: provider + model + star
HStack(spacing: 12) {
// Provider picker dropdown only shows configured providers
providerMenu
modelButton
starButton
Spacer()
}
// Center: conversation title (macOS document-title style)
conversationTitle
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
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 {
@@ -45,9 +99,7 @@ struct HeaderView: View {
HStack {
Image(systemName: p.iconName)
Text(p.displayName)
if p == provider {
Image(systemName: "checkmark")
}
if p == provider { Image(systemName: "checkmark") }
}
}
}
@@ -70,58 +122,46 @@ struct HeaderView: View {
.menuStyle(.borderlessButton)
.fixedSize()
.help("Switch provider")
}
// Model name (clickable model selector)
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)
// Capability badges
HStack(spacing: 3) {
if model.capabilities.vision {
Image(systemName: "eye")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
}
if model.capabilities.tools {
Image(systemName: "wrench")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
}
if model.capabilities.online {
Image(systemName: "globe")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
}
if model.capabilities.imageGeneration {
Image(systemName: "paintbrush")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
}
}
Image(systemName: "chevron.down")
.font(.caption2)
.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)
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
}
}
}
.buttonStyle(.plain)
.help("Select model")
}
// Favourite star
@ViewBuilder
private var starButton: some View {
if let model = model {
let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) {
@@ -132,18 +172,6 @@ struct HeaderView: View {
.buttonStyle(.plain)
.help(isFav ? "Remove from favorites" : "Add to favorites")
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .bottom
)
}
}
+38 -43
View File
@@ -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
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
}
#endif
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 {
+171
View File
@@ -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<String>?
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
}
}
@@ -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 <https://www.gnu.org/licenses/>.
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
}
}
}
}
}
@@ -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() {
-11
View File
@@ -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)
}
}
+12 -1
View File
@@ -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)
}
-46
View File
@@ -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 {