Merge pull request '2.4' (#6) from 2.4 into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-06-19 08:05:36 +02:00
35 changed files with 1620 additions and 632 deletions
+1
View File
@@ -4,6 +4,7 @@
## User settings ## User settings
xcuserdata/ xcuserdata/
xcshareddata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint *.xcscmblueprint
-11
View File
@@ -72,17 +72,6 @@ oAI/
## Building ## 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 ### Manual Build Commands
```bash ```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). 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 ## 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) - Blog: [https://blog.rune.pm](https://blog.rune.pm)
- Gitlab.pm: [@rune](https://gitlab.pm/rune) - Gitlab.pm: [@rune](https://gitlab.pm/rune)
## Contributing
Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instructions and project structure.
--- ---
+6 -6
View File
@@ -279,11 +279,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait 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 = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 2.3.9; MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -323,11 +323,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait 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 = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 2.3.9; MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -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 = "nb"
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
}
}
+8
View File
@@ -6,5 +6,13 @@
<string>oAI.help</string> <string>oAI.help</string>
<key>CFBundleHelpBookName</key> <key>CFBundleHelpBookName</key>
<string>oAI Help</string> <string>oAI Help</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>nb</string>
<string>da</string>
<string>de</string>
<string>sv</string>
</array>
</dict> </dict>
</plist> </plist>
+128
View File
@@ -1,6 +1,13 @@
{ {
"sourceLanguage" : "en", "sourceLanguage" : "en",
"strings" : { "strings" : {
"·" : {
"comment" : "A separator between the message count and the date.",
"isCommentAutoGenerated" : true
},
"· %@" : {
},
"(always used)" : { "(always used)" : {
"localizations" : { "localizations" : {
"da" : { "da" : {
@@ -432,6 +439,12 @@
} }
} }
} }
},
"^[%@ message](inflect: true)" : {
},
"^[%@ token](inflect: true)" : {
}, },
"© 2026 [Rune Olsen](https://blog.rune.pm)" : { "© 2026 [Rune Olsen](https://blog.rune.pm)" : {
"comment" : "A copyright notice with the copyright holder's name.", "comment" : "A copyright notice with the copyright holder's name.",
@@ -521,6 +534,7 @@
}, },
"⌘N New • ⌘M Model • ⌘S Save" : { "⌘N New • ⌘M Model • ⌘S Save" : {
"comment" : "A hint that appears on macOS when using keyboard shortcuts.", "comment" : "A hint that appears on macOS when using keyboard shortcuts.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"da" : { "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." : { "⚠️ Custom prompt active — only this prompt will be sent to the model." : {
"localizations" : { "localizations" : {
"da" : { "da" : {
@@ -894,6 +912,12 @@
} }
} }
} }
},
"🧠" : {
},
"1. Open Anytype → Settings → Integrations" : {
}, },
"1. Open Paperless-NGX → Settings → API Tokens" : { "1. Open Paperless-NGX → Settings → API Tokens" : {
"comment" : "A step in the process of getting a Paperless-NGX API token.", "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" : { "2. Create or copy your token" : {
"comment" : "A step in the process of getting a Paperless-NGX API token.", "comment" : "A step in the process of getting a Paperless-NGX API token.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -1132,6 +1160,9 @@
} }
} }
} }
},
"Agent" : {
}, },
"Agent Skills" : { "Agent Skills" : {
"localizations" : { "localizations" : {
@@ -1160,6 +1191,9 @@
} }
} }
} }
},
"Agents" : {
}, },
"Allow Shell Command?" : { "Allow Shell Command?" : {
"comment" : "A title for a modal that asks the user if they want to allow a 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." : { "Changing these values affects how the AI generates responses. The defaults work well for most use cases." : {
"localizations" : { "localizations" : {
@@ -1654,6 +1691,9 @@
} }
} }
} }
},
"Choose an agent from the list to view details and run history" : {
}, },
"Clear All" : { "Clear All" : {
"comment" : "A button to clear all email activity logs.", "comment" : "A button to clear all email activity logs.",
@@ -1918,6 +1958,9 @@
} }
} }
} }
},
"Cost" : {
}, },
"Cost Examples" : { "Cost Examples" : {
"comment" : "A heading for the cost examples of a model.", "comment" : "A heading for the cost examples of a model.",
@@ -2092,6 +2135,9 @@
} }
} }
} }
},
"Disabled" : {
}, },
"Each command will require your approval before running." : { "Each command will require your approval before running." : {
"localizations" : { "localizations" : {
@@ -2468,6 +2514,12 @@
} }
} }
} }
},
"Filter by Category" : {
},
"Generate an API key in your Jarvis settings and paste it above." : {
}, },
"Google (Gemini embedding)" : { "Google (Gemini embedding)" : {
"localizations" : { "localizations" : {
@@ -2526,6 +2578,12 @@
} }
} }
} }
},
"High (~80%)" : {
},
"How to get your API key:" : {
}, },
"How to get your API token:" : { "How to get your API token:" : {
"comment" : "A heading for a section that describes 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." : { "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.", "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." : { "Lowercase letters, numbers, and hyphens only. No spaces." : {
"comment" : "A description of the format of a shortcut's command.", "comment" : "A description of the format of a shortcut's command.",
@@ -2842,6 +2906,9 @@
} }
} }
} }
},
"Medium (~50%)" : {
}, },
"messages" : { "messages" : {
"localizations" : { "localizations" : {
@@ -2870,6 +2937,9 @@
} }
} }
} }
},
"Minimal (~10%)" : {
}, },
"Model Context Protocol" : { "Model Context Protocol" : {
"localizations" : { "localizations" : {
@@ -2928,6 +2998,9 @@
} }
} }
} }
},
"Model thinks internally but reasoning is not shown in chat" : {
}, },
"Multi-provider AI chat client" : { "Multi-provider AI chat client" : {
"comment" : "A description of oAI.", "comment" : "A description of oAI.",
@@ -3018,6 +3091,9 @@
} }
} }
} }
},
"New Chat" : {
}, },
"No credit data available" : { "No credit data available" : {
"comment" : "A message displayed when there is 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" : { "No shortcuts yet" : {
"comment" : "A message displayed when a user has no shortcuts.", "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)" : { "Ollama (Local)" : {
"comment" : "A label displayed above the credits information for the local Ollie.", "comment" : "A label displayed above the credits information for the local Ollie.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -3574,6 +3657,9 @@
} }
} }
} }
},
"OpenRouter Balance" : {
}, },
"OpenRouter Credits" : { "OpenRouter Credits" : {
"comment" : "A heading for the user's OpenRouter credits.", "comment" : "A heading for the user's OpenRouter credits.",
@@ -3604,6 +3690,12 @@
} }
} }
} }
},
"Output" : {
},
"Prompt" : {
}, },
"Read access (always enabled)" : { "Read access (always enabled)" : {
"localizations" : { "localizations" : {
@@ -3632,6 +3724,9 @@
} }
} }
} }
},
"Reasoning" : {
}, },
"Remote: %@" : { "Remote: %@" : {
"localizations" : { "localizations" : {
@@ -3690,6 +3785,12 @@
} }
} }
} }
},
"Run History" : {
},
"Run some agents to see usage statistics" : {
}, },
"Running locally — no credits needed!" : { "Running locally — no credits needed!" : {
"comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.", "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" : { "Security Recommendation" : {
"localizations" : { "localizations" : {
"da" : { "da" : {
@@ -3866,6 +3971,9 @@
} }
} }
} }
},
"Sort" : {
}, },
"SSH Key" : { "SSH Key" : {
"localizations" : { "localizations" : {
@@ -4180,6 +4288,9 @@
} }
} }
} }
},
"Thinking…" : {
}, },
"This default prompt is always included to ensure accurate, helpful responses." : { "This default prompt is always included to ensure accurate, helpful responses." : {
"localizations" : { "localizations" : {
@@ -4296,6 +4407,15 @@
} }
} }
} }
},
"Total" : {
},
"Total Credits" : {
},
"Total Used" : {
}, },
"Try adjusting your search or filters" : { "Try adjusting your search or filters" : {
"comment" : "A description of the error that occurs when no models match the user's search.", "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" : { "Use @filename to attach files to your message" : {
"comment" : "A description of how to attach files to a message.", "comment" : "A description of how to attach files to a message.",
@@ -4565,6 +4688,7 @@
}, },
"v%@" : { "v%@" : {
"comment" : "A label showing the current version of oAI.", "comment" : "A label showing the current version of oAI.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"da" : { "da" : {
@@ -4744,6 +4868,10 @@
} }
} }
} }
},
"β" : {
"comment" : "A beta badge.",
"isCommentAutoGenerated" : true
} }
}, },
"version" : "1.1" "version" : "1.1"
+1 -1
View File
@@ -33,7 +33,7 @@ struct Conversation: Identifiable, Codable {
var updatedAt: Date var updatedAt: Date
var primaryModel: String? // Primary model used in this conversation var primaryModel: String? // Primary model used in this conversation
init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
name: String, name: String,
messages: [Message] = [], 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 responseTime: TimeInterval? // Time to generate response in seconds
let modelId: String? // Model that handled the email let modelId: String? // Model that handled the email
init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
timestamp: Date = Date(), timestamp: Date = Date(),
sender: String, sender: String,
+1 -1
View File
@@ -66,7 +66,7 @@ struct Message: Identifiable, Codable, Equatable {
// Reasoning/thinking content (not persisted in-memory only) // Reasoning/thinking content (not persisted in-memory only)
var thinkingContent: String? = nil var thinkingContent: String? = nil
init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
role: MessageRole, role: MessageRole,
content: String, content: String,
+12
View File
@@ -130,11 +130,23 @@ struct ChatResponse: Codable {
let promptTokens: Int let promptTokens: Int
let completionTokens: Int let completionTokens: Int
let totalTokens: 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 { enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens" case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens" case completionTokens = "completion_tokens"
case totalTokens = "total_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") /// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301")
/// still inherit the correct pricing tier. /// still inherit the correct pricing tier.
private static let knownModels: [ModelInfo] = [ 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 // Claude 4.x series
ModelInfo( ModelInfo(
id: "claude-opus-4-6", id: "claude-opus-4-6",
@@ -173,6 +182,7 @@ class AnthropicProvider: AIProvider {
/// Pricing tiers used for fuzzy fallback matching on unknown model IDs. /// Pricing tiers used for fuzzy fallback matching on unknown model IDs.
/// Keyed by model name prefix (longest match wins). /// Keyed by model name prefix (longest match wins).
private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [ private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [
("claude-fable", 10.0, 50.0),
("claude-opus", 15.0, 75.0), ("claude-opus", 15.0, 75.0),
("claude-sonnet", 3.0, 15.0), ("claude-sonnet", 3.0, 15.0),
("claude-haiku", 0.80, 4.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] = [ var body: [String: Any] = [
"model": model, "model": model,
"messages": conversationMessages, "messages": conversationMessages,
@@ -363,7 +386,9 @@ class AnthropicProvider: AIProvider {
"stream": false "stream": false
] ]
if let systemText = systemText { 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 { if let temperature = temperature {
body["temperature"] = temperature body["temperature"] = temperature
@@ -430,6 +455,8 @@ class AnthropicProvider: AIProvider {
var currentId = "" var currentId = ""
var currentModel = request.model var currentModel = request.model
var inputTokens = 0 var inputTokens = 0
var cacheCreationTokens: Int? = nil
var cacheReadTokens: Int? = nil
for try await line in bytes.lines { for try await line in bytes.lines {
// Anthropic SSE: "event: ..." and "data: {...}" // Anthropic SSE: "event: ..." and "data: {...}"
@@ -449,6 +476,11 @@ class AnthropicProvider: AIProvider {
currentModel = message["model"] as? String ?? request.model currentModel = message["model"] as? String ?? request.model
if let usageDict = message["usage"] as? [String: Any] { if let usageDict = message["usage"] as? [String: Any] {
inputTokens = usageDict["input_tokens"] as? Int ?? 0 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 var usage: ChatResponse.Usage? = nil
if let usageDict = event["usage"] as? [String: Any] { if let usageDict = event["usage"] as? [String: Any] {
let outputTokens = usageDict["output_tokens"] as? Int ?? 0 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( continuation.yield(StreamChunk(
id: currentId, 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] = [ var body: [String: Any] = [
"model": request.model, "model": request.model,
"messages": apiMessages, "messages": apiMessages,
@@ -590,7 +641,10 @@ class AnthropicProvider: AIProvider {
] ]
if let systemText = systemText { 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 { if let temperature = request.temperature {
body["temperature"] = temperature body["temperature"] = temperature
@@ -665,6 +719,11 @@ class AnthropicProvider: AIProvider {
let usageDict = json["usage"] as? [String: Any] let usageDict = json["usage"] as? [String: Any]
let inputTokens = usageDict?["input_tokens"] as? Int ?? 0 let inputTokens = usageDict?["input_tokens"] as? Int ?? 0
let outputTokens = usageDict?["output_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( return ChatResponse(
id: id, id: id,
@@ -675,7 +734,9 @@ class AnthropicProvider: AIProvider {
usage: ChatResponse.Usage( usage: ChatResponse.Usage(
promptTokens: inputTokens, promptTokens: inputTokens,
completionTokens: outputTokens, completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens totalTokens: inputTokens + outputTokens,
cacheCreationInputTokens: cacheCreationTokens,
cacheReadInputTokens: cacheReadTokens
), ),
created: Date(), created: Date(),
toolCalls: toolCalls.isEmpty ? nil : toolCalls toolCalls: toolCalls.isEmpty ? nil : toolCalls
+18
View File
@@ -48,6 +48,11 @@ struct OpenRouterChatRequest: Codable {
let toolChoice: String? let toolChoice: String?
let modalities: [String]? let modalities: [String]?
let reasoning: ReasoningAPIConfig? let reasoning: ReasoningAPIConfig?
let cacheControl: CacheControl?
struct CacheControl: Codable {
let type: String
}
struct APIMessage: Codable { struct APIMessage: Codable {
let role: String let role: String
@@ -138,6 +143,7 @@ struct OpenRouterChatRequest: Codable {
case toolChoice = "tool_choice" case toolChoice = "tool_choice"
case modalities case modalities
case reasoning case reasoning
case cacheControl = "cache_control"
} }
} }
@@ -225,11 +231,23 @@ struct OpenRouterChatResponse: Codable {
let promptTokens: Int let promptTokens: Int
let completionTokens: Int let completionTokens: Int
let totalTokens: 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 { enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens" case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens" case completionTokens = "completion_tokens"
case totalTokens = "total_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 maxTokens = maxTokens { body["max_tokens"] = maxTokens }
if let temperature = temperature { body["temperature"] = temperature } 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) var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST" urlRequest.httpMethod = "POST"
@@ -388,6 +393,12 @@ class OpenRouterProvider: AIProvider {
ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil) 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( return OpenRouterChatRequest(
model: effectiveModel, model: effectiveModel,
messages: apiMessages, messages: apiMessages,
@@ -398,7 +409,8 @@ class OpenRouterProvider: AIProvider {
tools: request.tools, tools: request.tools,
toolChoice: request.tools != nil ? "auto" : nil, toolChoice: request.tools != nil ? "auto" : nil,
modalities: request.imageGeneration ? ["text", "image"] : 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 allImages = topLevelImages + blockImages
let images: [Data]? = allImages.isEmpty ? nil : allImages 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( return ChatResponse(
id: apiResponse.id, id: apiResponse.id,
model: apiResponse.model, model: apiResponse.model,
@@ -426,7 +443,9 @@ class OpenRouterProvider: AIProvider {
ChatResponse.Usage( ChatResponse.Usage(
promptTokens: usage.promptTokens, promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens, completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens totalTokens: usage.totalTokens,
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
) )
}, },
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)), created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
@@ -446,6 +465,11 @@ class OpenRouterProvider: AIProvider {
let allImages = topLevelImages + blockImages let allImages = topLevelImages + blockImages
let images: [Data]? = allImages.isEmpty ? nil : allImages 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( return StreamChunk(
id: apiChunk.id, id: apiChunk.id,
model: apiChunk.model, model: apiChunk.model,
@@ -460,7 +484,9 @@ class OpenRouterProvider: AIProvider {
ChatResponse.Usage( ChatResponse.Usage(
promptTokens: usage.promptTokens, promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens, completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens totalTokens: usage.totalTokens,
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
) )
} }
) )
+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
}
}
+41 -27
View File
@@ -134,15 +134,29 @@ final class DatabaseService: Sendable {
nonisolated static let shared = DatabaseService() nonisolated static let shared = DatabaseService()
private let dbQueue: DatabaseQueue private let dbQueue: DatabaseQueue
private let isoFormatter: ISO8601DateFormatter
// Command history limit - keep most recent 5000 entries // 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() { nonisolated private init() {
isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fileManager = FileManager.default let fileManager = FileManager.default
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true) let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
@@ -156,7 +170,7 @@ final class DatabaseService: Sendable {
try! migrator.migrate(dbQueue) try! migrator.migrate(dbQueue)
} }
private var migrator: DatabaseMigrator { private nonisolated var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator() var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in 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 { 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"))") Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
let now = Date() let now = Date()
let nowString = isoFormatter.string(from: now) let nowString = Self.isoString(from: now)
let convRecord = ConversationRecord( let convRecord = ConversationRecord(
id: id.uuidString, id: id.uuidString,
@@ -394,7 +408,7 @@ final class DatabaseService: Sendable {
content: msg.content, content: msg.content,
tokens: msg.tokens, tokens: msg.tokens,
cost: msg.cost, cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp), timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index, sortOrder: index,
modelId: msg.modelId modelId: msg.modelId
) )
@@ -420,7 +434,7 @@ final class DatabaseService: Sendable {
/// Update an existing conversation in-place: rename it, replace all its messages. /// Update an existing conversation in-place: rename it, replace all its messages.
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws { 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 let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil } guard msg.role != .system else { return nil }
@@ -431,7 +445,7 @@ final class DatabaseService: Sendable {
content: msg.content, content: msg.content,
tokens: msg.tokens, tokens: msg.tokens,
cost: msg.cost, cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp), timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index, sortOrder: index,
modelId: msg.modelId modelId: msg.modelId
) )
@@ -466,7 +480,7 @@ final class DatabaseService: Sendable {
let messages = messageRecords.compactMap { record -> Message? in let messages = messageRecords.compactMap { record -> Message? in
guard let msgId = UUID(uuidString: record.id), guard let msgId = UUID(uuidString: record.id),
let role = MessageRole(rawValue: record.role), let role = MessageRole(rawValue: record.role),
let timestamp = self.isoFormatter.date(from: record.timestamp) let timestamp = Self.isoDate(from: record.timestamp)
else { return nil } else { return nil }
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1 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), guard let convId = UUID(uuidString: convRecord.id),
let createdAt = self.isoFormatter.date(from: convRecord.createdAt), let createdAt = Self.isoDate(from: convRecord.createdAt),
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt) let updatedAt = Self.isoDate(from: convRecord.updatedAt)
else { return nil } else { return nil }
let conversation = Conversation( let conversation = Conversation(
@@ -509,8 +523,8 @@ final class DatabaseService: Sendable {
return records.compactMap { record -> Conversation? in return records.compactMap { record -> Conversation? in
guard let id = UUID(uuidString: record.id), guard let id = UUID(uuidString: record.id),
let createdAt = self.isoFormatter.date(from: record.createdAt), let createdAt = Self.isoDate(from: record.createdAt),
let updatedAt = self.isoFormatter.date(from: record.updatedAt) let updatedAt = Self.isoDate(from: record.updatedAt)
else { return nil } else { return nil }
// Fetch message count without loading all messages // Fetch message count without loading all messages
@@ -524,7 +538,7 @@ final class DatabaseService: Sendable {
.order(Column("sortOrder").desc) .order(Column("sortOrder").desc)
.fetchOne(db) .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 // Derive primary model: prefer the stored field, fall back to last message's modelId
let primaryModel = record.primaryModel ?? lastMsg?.modelId let primaryModel = record.primaryModel ?? lastMsg?.modelId
@@ -574,7 +588,7 @@ final class DatabaseService: Sendable {
convRecord.name = name convRecord.name = name
} }
convRecord.updatedAt = self.isoFormatter.string(from: Date()) convRecord.updatedAt = Self.isoString(from: Date())
try convRecord.update(db) try convRecord.update(db)
if let messages = messages { if let messages = messages {
@@ -589,7 +603,7 @@ final class DatabaseService: Sendable {
content: msg.content, content: msg.content,
tokens: msg.tokens, tokens: msg.tokens,
cost: msg.cost, cost: msg.cost,
timestamp: self.isoFormatter.string(from: msg.timestamp), timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index sortOrder: index
) )
} }
@@ -610,7 +624,7 @@ final class DatabaseService: Sendable {
let record = HistoryRecord( let record = HistoryRecord(
id: UUID().uuidString, id: UUID().uuidString,
input: input, input: input,
timestamp: isoFormatter.string(from: now) timestamp: Self.isoString(from: now)
) )
try? dbQueue.write { db in try? dbQueue.write { db in
@@ -643,7 +657,7 @@ final class DatabaseService: Sendable {
.fetchAll(db) .fetchAll(db)
return records.compactMap { record in 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 nil
} }
return (input: record.input, timestamp: date) return (input: record.input, timestamp: date)
@@ -659,7 +673,7 @@ final class DatabaseService: Sendable {
.fetchAll(db) .fetchAll(db)
return records.compactMap { record in 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 nil
} }
return (input: record.input, timestamp: date) return (input: record.input, timestamp: date)
@@ -672,7 +686,7 @@ final class DatabaseService: Sendable {
nonisolated func saveEmailLog(_ log: EmailLog) { nonisolated func saveEmailLog(_ log: EmailLog) {
let record = EmailLogRecord( let record = EmailLogRecord(
id: log.id.uuidString, id: log.id.uuidString,
timestamp: isoFormatter.string(from: log.timestamp), timestamp: Self.isoString(from: log.timestamp),
sender: log.sender, sender: log.sender,
subject: log.subject, subject: log.subject,
emailContent: log.emailContent, emailContent: log.emailContent,
@@ -698,7 +712,7 @@ final class DatabaseService: Sendable {
.fetchAll(db) .fetchAll(db)
return records.compactMap { record in 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 status = EmailLogStatus(rawValue: record.status),
let id = UUID(uuidString: record.id) else { let id = UUID(uuidString: record.id) else {
return nil return nil
@@ -805,7 +819,7 @@ final class DatabaseService: Sendable {
// MARK: - Embedding Operations // MARK: - Embedding Operations
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws { 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( let record = MessageEmbeddingRecord(
message_id: messageId.uuidString, message_id: messageId.uuidString,
embedding: embedding, embedding: embedding,
@@ -825,7 +839,7 @@ final class DatabaseService: Sendable {
} }
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws { 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( let record = ConversationEmbeddingRecord(
conversation_id: conversationId.uuidString, conversation_id: conversationId.uuidString,
embedding: embedding, embedding: embedding,
@@ -881,7 +895,7 @@ final class DatabaseService: Sendable {
return Array(results.prefix(limit)) return Array(results.prefix(limit))
} }
private func deserializeEmbedding(_ data: Data) -> [Float] { private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
var embedding: [Float] = [] var embedding: [Float] = []
embedding.reserveCapacity(data.count / 4) embedding.reserveCapacity(data.count / 4)
@@ -905,7 +919,7 @@ final class DatabaseService: Sendable {
model: String?, model: String?,
tokenCount: Int? tokenCount: Int?
) throws { ) throws {
let now = isoFormatter.string(from: Date()) let now = Self.isoString(from: Date())
let record = ConversationSummaryRecord( let record = ConversationSummaryRecord(
id: UUID().uuidString, id: UUID().uuidString,
conversation_id: conversationId.uuidString, conversation_id: conversationId.uuidString,
+2 -2
View File
@@ -71,7 +71,7 @@ enum EmbeddingProvider {
// MARK: - Embedding Service // MARK: - Embedding Service
final class EmbeddingService { final class EmbeddingService {
static let shared = EmbeddingService() nonisolated static let shared = EmbeddingService()
private let settings = SettingsService.shared private let settings = SettingsService.shared
@@ -281,7 +281,7 @@ final class EmbeddingService {
// MARK: - Similarity Calculation // MARK: - Similarity Calculation
/// Calculate cosine similarity between two embeddings /// 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 { guard a.count == b.count else {
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)") Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
return 0.0 return 0.0
+10 -13
View File
@@ -29,19 +29,18 @@ import CryptoKit
import IOKit import IOKit
class EncryptionService { class EncryptionService {
static let shared = EncryptionService() nonisolated static let shared = EncryptionService()
private let salt = "oAI-secure-storage-v1" // App-specific salt private let encryptionKey: SymmetricKey
private lazy var encryptionKey: SymmetricKey = {
deriveEncryptionKey()
}()
private init() {} private init() {
self.encryptionKey = Self.deriveEncryptionKey()
}
// MARK: - Public Interface // MARK: - Public Interface
/// Encrypt a string value /// 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 { guard let data = value.data(using: .utf8) else {
throw EncryptionError.invalidInput throw EncryptionError.invalidInput
} }
@@ -55,7 +54,7 @@ class EncryptionService {
} }
/// Decrypt a string value /// Decrypt a string value
func decrypt(_ encryptedValue: String) throws -> String { nonisolated func decrypt(_ encryptedValue: String) throws -> String {
guard let data = Data(base64Encoded: encryptedValue) else { guard let data = Data(base64Encoded: encryptedValue) else {
throw EncryptionError.invalidInput throw EncryptionError.invalidInput
} }
@@ -73,19 +72,17 @@ class EncryptionService {
// MARK: - Key Derivation // MARK: - Key Derivation
/// Derive encryption key from machine-specific data /// Derive encryption key from machine-specific data
private func deriveEncryptionKey() -> SymmetricKey { private static func deriveEncryptionKey() -> SymmetricKey {
// Combine machine UUID + bundle ID + salt for key material
let machineUUID = getMachineUUID() let machineUUID = getMachineUUID()
let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI" let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI"
let salt = "oAI-secure-storage-v1"
let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)" let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)"
// Hash to create consistent 256-bit key
let hash = SHA256.hash(data: Data(keyMaterial.utf8)) let hash = SHA256.hash(data: Data(keyMaterial.utf8))
return SymmetricKey(data: hash) return SymmetricKey(data: hash)
} }
/// Get machine-specific UUID (IOPlatformUUID) /// Get machine-specific UUID (IOPlatformUUID)
private func getMachineUUID() -> String { private static func getMachineUUID() -> String {
// Get IOPlatformUUID from IOKit // Get IOPlatformUUID from IOKit
let platformExpert = IOServiceGetMatchingService( let platformExpert = IOServiceGetMatchingService(
kIOMainPortDefault, kIOMainPortDefault,
+1 -1
View File
@@ -212,7 +212,7 @@ class GitSyncService {
// Check if conversation already exists (by ID) // Check if conversation already exists (by ID)
if let existingId = UUID(uuidString: export.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 // Already exists - skip
log.debug("Skipping existing conversation: \(export.name)") log.debug("Skipping existing conversation: \(export.name)")
skipped += 1 skipped += 1
+18
View File
@@ -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 // MARK: - MCP Permissions
var mcpCanWriteFiles: Bool { var mcpCanWriteFiles: Bool {
+30
View File
@@ -35,6 +35,11 @@ final class UpdateCheckService {
var updateAvailable: Bool = false var updateAvailable: Bool = false
var latestVersion: String? = nil 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 apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")! 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 { private func performCheck() async {
guard let url = URL(string: apiURL) else { return } 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 latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" 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) { if isNewer(latestVer, than: currentVer) {
await MainActor.run { await MainActor.run {
self.latestVersion = latestVer self.latestVersion = latestVer
self.downloadURL = dmgURL
self.updateAvailable = true self.updateAvailable = true
} }
} }
+25 -23
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 lhs.rawValue < rhs.rawValue
} }
} }
@@ -60,7 +60,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable {
// MARK: - File Logger // MARK: - File Logger
final class FileLogger: @unchecked Sendable { final class FileLogger: @unchecked Sendable {
static let shared = FileLogger() nonisolated static let shared = FileLogger()
private let fileHandle: FileHandle? private let fileHandle: FileHandle?
private let queue = DispatchQueue(label: "com.oai.filelogger") private let queue = DispatchQueue(label: "com.oai.filelogger")
@@ -70,8 +70,8 @@ final class FileLogger: @unchecked Sendable {
return f return f
}() }()
/// Current minimum log level (read from UserDefaults for thread safety) /// Current minimum log level (backed by UserDefaults thread-safe).
var minimumLevel: LogLevel { nonisolated var minimumLevel: LogLevel {
get { get {
let raw = UserDefaults.standard.integer(forKey: "logLevel") let raw = UserDefaults.standard.integer(forKey: "logLevel")
return LogLevel(rawValue: raw) ?? .info return LogLevel(rawValue: raw) ?? .info
@@ -95,7 +95,7 @@ final class FileLogger: @unchecked Sendable {
fileHandle?.seekToEndOfFile() fileHandle?.seekToEndOfFile()
} }
func write(_ level: LogLevel, category: String, message: String) { nonisolated func write(_ level: LogLevel, category: String, message: String) {
guard level >= minimumLevel else { return } guard level >= minimumLevel else { return }
queue.async { [weak self] in queue.async { [weak self] in
guard let self, let fh = self.fileHandle else { return } guard let self, let fh = self.fileHandle else { return }
@@ -114,41 +114,43 @@ final class FileLogger: @unchecked Sendable {
// MARK: - App Logger (wraps os.Logger + file) // MARK: - App Logger (wraps os.Logger + file)
struct AppLogger { // os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are
let osLogger: Logger // nonisolated FileLogger runs on its own serial queue, os.Logger dispatches to main actor.
struct AppLogger: Sendable {
let subsystem: String
let category: String let category: String
func debug(_ message: String) { nonisolated func debug(_ message: String) {
FileLogger.shared.write(.debug, category: category, message: message) 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) 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) 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) 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 // MARK: - Log Namespace
enum Log { 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") nonisolated static let api = AppLogger(subsystem: subsystem, category: "api")
static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database") nonisolated static let db = AppLogger(subsystem: subsystem, category: "database")
static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp") nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp")
static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings") nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings")
static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search") nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui") nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general") nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
} }
+17 -14
View File
@@ -934,10 +934,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages[index].tokens = usage.completionTokens messages[index].tokens = usage.completionTokens
if let model = selectedModel { if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
: nil
messages[index].cost = cost messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, 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 messages[index].tokens = usage.completionTokens
if let model = selectedModel { if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
: nil
messages[index].cost = cost messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
} }
@@ -1313,7 +1307,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Append the complete system prompt (default + custom) // Append the complete system prompt (default + custom)
systemContent += "\n\n---\n\n" + effectiveSystemPrompt systemContent += "\n\n---\n\n" + effectiveSystemPrompt
var messagesToSend: [Message] = memoryEnabled let messagesToSend: [Message] = memoryEnabled
? messages.filter { $0.role != .system } ? messages.filter { $0.role != .system }
: [messages.last(where: { $0.role == .user })].compactMap { $0 } : [messages.last(where: { $0.role == .user })].compactMap { $0 }
@@ -1529,10 +1523,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Calculate cost // Calculate cost
if let usage = totalUsage, let model = selectedModel { if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
: nil
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) { if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost messages[index].cost = cost
} }
@@ -1659,7 +1650,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
if isOverloaded && attempt < maxAttempts && !Task.isCancelled { if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
let delay = Double(1 << attempt) // 2s, 4s, 8s let delay = Double(1 << attempt) // 2s, 4s, 8s
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...") 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))") showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
} }
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
@@ -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 /// Summarize a chunk of messages into a concise summary
private func summarizeMessageChunk(_ messages: [Message]) async -> String? { private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
guard let provider = providerRegistry.getProvider(for: currentProvider), guard let provider = providerRegistry.getProvider(for: currentProvider),
+12 -8
View File
@@ -37,12 +37,11 @@ struct ChatView: View {
HeaderView( HeaderView(
provider: viewModel.currentProvider, provider: viewModel.currentProvider,
model: viewModel.selectedModel, model: viewModel.selectedModel,
stats: viewModel.sessionStats,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled,
mcpStatus: viewModel.mcpStatus,
onModelSelect: onModelSelect, onModelSelect: onModelSelect,
onProviderChange: onProviderChange onProviderChange: onProviderChange,
conversationName: viewModel.currentConversationName,
hasUnsavedChanges: viewModel.hasUnsavedChanges,
onQuickSave: viewModel.quickSave
) )
// Messages // Messages
@@ -85,10 +84,13 @@ struct ChatView: View {
InputBar( InputBar(
text: $viewModel.inputText, text: $viewModel.inputText,
isGenerating: viewModel.isGenerating, isGenerating: viewModel.isGenerating,
mcpStatus: viewModel.mcpStatus,
onlineMode: viewModel.onlineMode, onlineMode: viewModel.onlineMode,
onSend: viewModel.sendMessage, onSend: viewModel.sendMessage,
onCancel: viewModel.cancelGeneration onCancel: viewModel.cancelGeneration,
onToggleOnline: {
viewModel.onlineMode.toggle()
SettingsService.shared.onlineMode = viewModel.onlineMode
}
) )
// Footer // Footer
@@ -96,7 +98,9 @@ struct ChatView: View {
stats: viewModel.sessionStats, stats: viewModel.sessionStats,
conversationName: viewModel.currentConversationName, conversationName: viewModel.currentConversationName,
hasUnsavedChanges: viewModel.hasUnsavedChanges, hasUnsavedChanges: viewModel.hasUnsavedChanges,
onQuickSave: viewModel.quickSave onQuickSave: viewModel.quickSave,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled
) )
} }
.background(Color.oaiBackground) .background(Color.oaiBackground)
+53 -119
View File
@@ -2,7 +2,7 @@
// ContentView.swift // ContentView.swift
// oAI // oAI
// //
// Root navigation container // Root navigation container NavigationSplitView with collapsible sidebar
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen // Copyright (C) 2026 Rune Olsen
@@ -24,30 +24,34 @@
import SwiftUI import SwiftUI
#if os(macOS)
import Darwin // uname, sysctlbyname
#endif
struct ContentView: View { struct ContentView: View {
@Environment(ChatViewModel.self) var chatViewModel @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 { var body: some View {
@Bindable var vm = chatViewModel @Bindable var vm = chatViewModel
NavigationStack { NavigationSplitView(columnVisibility: $columnVisibility) {
SidebarView()
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
} detail: {
ChatView( ChatView(
onModelSelect: { chatViewModel.showModelSelector = true }, onModelSelect: { chatViewModel.showModelSelector = true },
onProviderChange: { newProvider in onProviderChange: { newProvider in
chatViewModel.changeProvider(newProvider) chatViewModel.changeProvider(newProvider)
} }
) )
.navigationTitle("")
.toolbar {
#if os(macOS)
macOSToolbar
#endif
}
} }
.frame(minWidth: 860, minHeight: 560) .frame(minWidth: 860, minHeight: 560)
#if os(macOS) #if os(macOS)
.onAppear { .onAppear {
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed } NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
checkIntelWarning()
} }
.onKeyPress(.return, phases: .down) { press in .onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.command) { if press.modifiers.contains(.command) {
@@ -65,7 +69,6 @@ struct ContentView: View {
let oldModel = chatViewModel.selectedModel let oldModel = chatViewModel.selectedModel
chatViewModel.selectModel(model) chatViewModel.selectModel(model)
chatViewModel.showModelSelector = false chatViewModel.showModelSelector = false
// Trigger auto-save on model switch
Task { Task {
await chatViewModel.onModelSwitch(from: oldModel, to: model) await chatViewModel.onModelSwitch(from: oldModel, to: model)
} }
@@ -113,125 +116,56 @@ struct ContentView: View {
chatViewModel.inputText = input 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) #if os(macOS)
@ToolbarContentBuilder private func checkIntelWarning() {
private var macOSToolbar: some ToolbarContent { guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
let settings = SettingsService.shared guard isIntelNative || isRosetta else { return }
let showLabels = settings.showToolbarLabels showIntelWarning = true
let iconSize = settings.toolbarIconSize
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 }) { private var isIntelNative: Bool {
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize) var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) {
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
}
return machine.contains("x86_64")
} }
.keyboardShortcut("l", modifiers: .command)
.help("Saved conversations (Cmd+L)")
Button(action: { chatViewModel.showHistory = true }) { private var isRosetta: Bool {
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize) var ret: Int32 = 0
} var size = MemoryLayout<Int32>.size
.keyboardShortcut("h", modifiers: .command) sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
.help("Command history (Cmd+H)") return ret == 1
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+/)")
}
} }
#endif #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 3418pt, 3620pt, 3822pt, 4024pt
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 { #Preview {
+22 -20
View File
@@ -30,15 +30,22 @@ struct FooterView: View {
let conversationName: String? let conversationName: String?
let hasUnsavedChanges: Bool let hasUnsavedChanges: Bool
let onQuickSave: (() -> Void)? let onQuickSave: (() -> Void)?
let onlineMode: Bool
let mcpEnabled: Bool
private let settings = SettingsService.shared
init(stats: SessionStats, init(stats: SessionStats,
conversationName: String? = nil, conversationName: String? = nil,
hasUnsavedChanges: Bool = false, hasUnsavedChanges: Bool = false,
onQuickSave: (() -> Void)? = nil) { onQuickSave: (() -> Void)? = nil,
onlineMode: Bool = false,
mcpEnabled: Bool = false) {
self.stats = stats self.stats = stats
self.conversationName = conversationName self.conversationName = conversationName
self.hasUnsavedChanges = hasUnsavedChanges self.hasUnsavedChanges = hasUnsavedChanges
self.onQuickSave = onQuickSave self.onQuickSave = onQuickSave
self.onlineMode = onlineMode
self.mcpEnabled = mcpEnabled
} }
var body: some View { var body: some View {
@@ -71,26 +78,26 @@ struct FooterView: View {
Spacer() Spacer()
// Save indicator (only when chat has messages) // Status pills Online, MCP, Sync
if stats.messageCount > 0 { #if os(macOS)
SaveIndicator( HStack(spacing: 6) {
conversationName: conversationName, if onlineMode {
hasUnsavedChanges: hasUnsavedChanges, StatusPill(icon: "globe", label: "Online", color: .green)
onSave: onQuickSave
)
} }
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
if settings.syncEnabled && settings.syncAutoSave {
SyncStatusPill()
}
}
#endif
// Update available badge // Update available badge (shows only when an update exists no version number)
#if os(macOS) #if os(macOS)
UpdateBadge() UpdateBadge()
#endif #endif
// Shortcuts hint
#if os(macOS)
Text("⌘N New • ⌘M Model • ⌘S Save")
.font(.caption2)
.foregroundColor(.oaiSecondary)
#endif
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -242,7 +249,6 @@ struct SyncStatusFooter: View {
struct UpdateBadge: View { struct UpdateBadge: View {
private let updater = UpdateCheckService.shared private let updater = UpdateCheckService.shared
private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
var body: some View { var body: some View {
if updater.updateAvailable { if updater.updateAvailable {
@@ -258,10 +264,6 @@ struct UpdateBadge: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("A new version is available — click to open the releases page") .help("A new version is available — click to open the releases page")
} else {
Text("v\(currentVersion)")
.font(.caption2)
.foregroundColor(.oaiSecondary)
} }
} }
} }
+71 -102
View File
@@ -2,7 +2,8 @@
// HeaderView.swift // HeaderView.swift
// oAI // 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 // SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen // Copyright (C) 2026 Rune Olsen
@@ -28,19 +29,68 @@ import SwiftUI
struct HeaderView: View { struct HeaderView: View {
let provider: Settings.Provider let provider: Settings.Provider
let model: ModelInfo? let model: ModelInfo?
let stats: SessionStats
let onlineMode: Bool
let mcpEnabled: Bool
let mcpStatus: String?
let onModelSelect: () -> Void let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> 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 settings = SettingsService.shared
private let registry = ProviderRegistry.shared private let registry = ProviderRegistry.shared
private let gitSync = GitSyncService.shared
var body: some View { var body: some View {
HStack(spacing: 20) { ZStack {
// Provider picker dropdown only shows configured providers // Left: provider + model + star
HStack(spacing: 12) {
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 { Menu {
ForEach(registry.configuredProviders, id: \.self) { p in ForEach(registry.configuredProviders, id: \.self) { p in
Button { Button {
@@ -49,9 +99,7 @@ struct HeaderView: View {
HStack { HStack {
Image(systemName: p.iconName) Image(systemName: p.iconName)
Text(p.displayName) Text(p.displayName)
if p == provider { if p == provider { Image(systemName: "checkmark") }
Image(systemName: "checkmark")
}
} }
} }
} }
@@ -74,58 +122,46 @@ struct HeaderView: View {
.menuStyle(.borderlessButton) .menuStyle(.borderlessButton)
.fixedSize() .fixedSize()
.help("Switch provider") .help("Switch provider")
}
// Model info (clickable model selector) private var modelButton: some View {
Button(action: onModelSelect) { Button(action: onModelSelect) {
if let model = model { if let model = model {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(model.name) Text(model.name)
.font(.system(size: settings.guiTextSize, weight: .medium)) .font(.system(size: settings.guiTextSize, weight: .medium))
.foregroundColor(.oaiPrimary) .foregroundColor(.oaiPrimary)
// Capability badges
HStack(spacing: 3) { HStack(spacing: 3) {
if model.capabilities.vision { if model.capabilities.vision {
Image(systemName: "eye") Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
if model.capabilities.tools { if model.capabilities.tools {
Image(systemName: "wrench") Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
if model.capabilities.online { if model.capabilities.online {
Image(systemName: "globe") Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
if model.capabilities.imageGeneration { if model.capabilities.imageGeneration {
Image(systemName: "paintbrush") Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
} }
} }
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
} }
} else { } else {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("No model selected") Text("No model selected")
.font(.system(size: settings.guiTextSize)) .font(.system(size: settings.guiTextSize))
.foregroundColor(.oaiSecondary) .foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
} }
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Select model") .help("Select model")
}
@ViewBuilder
private var starButton: some View {
if let model = model { if let model = model {
let isFav = settings.favoriteModelIds.contains(model.id) let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) { Button(action: { settings.toggleFavoriteModel(model.id) }) {
@@ -136,68 +172,10 @@ struct HeaderView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.help(isFav ? "Remove from favorites" : "Add to favorites") .help(isFav ? "Remove from favorites" : "Add to favorites")
} }
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 // MARK: - Status Pills (used by SidebarView)
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)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .bottom
)
}
}
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)
}
}
}
struct StatusPill: View { struct StatusPill: View {
let icon: String let icon: String
@@ -284,15 +262,6 @@ struct SyncStatusPill: View {
HeaderView( HeaderView(
provider: .openrouter, provider: .openrouter,
model: ModelInfo.mockModels.first, model: ModelInfo.mockModels.first,
stats: SessionStats(
totalInputTokens: 125,
totalOutputTokens: 434,
totalCost: 0.00111,
messageCount: 4
),
onlineMode: true,
mcpEnabled: true,
mcpStatus: "MCP",
onModelSelect: {}, onModelSelect: {},
onProviderChange: { _ in } onProviderChange: { _ in }
) )
+125 -130
View File
@@ -2,7 +2,7 @@
// InputBar.swift // InputBar.swift
// oAI // oAI
// //
// Message input bar with status indicators // Message input bar with resizable height and online toggle
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen // Copyright (C) 2026 Rune Olsen
@@ -24,19 +24,30 @@
import SwiftUI import SwiftUI
#if os(macOS)
import AppKit
#endif
struct InputBar: View { struct InputBar: View {
@Binding var text: String @Binding var text: String
let isGenerating: Bool let isGenerating: Bool
let mcpStatus: String?
let onlineMode: Bool let onlineMode: Bool
let onSend: () -> Void let onSend: () -> Void
let onCancel: () -> Void let onCancel: () -> Void
let onToggleOnline: () -> Void
private let settings = SettingsService.shared 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 showCommandDropdown = false
@State private var selectedSuggestionIndex: Int = 0 @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
/// Commands that execute immediately without additional arguments /// Commands that execute immediately without additional arguments
private static let immediateCommands: Set<String> = [ private static let immediateCommands: Set<String> = [
@@ -56,110 +67,98 @@ struct InputBar: View {
CommandSuggestionsView( CommandSuggestionsView(
searchText: text, searchText: text,
selectedIndex: selectedSuggestionIndex, selectedIndex: selectedSuggestionIndex,
onSelect: { command in onSelect: selectCommand
selectCommand(command)
}
) )
.frame(width: 400) .frame(width: 400)
.frame(maxHeight: 200) .frame(maxHeight: 200)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
Spacer() Spacer()
} }
.padding(.leading, 96) // Align with input box (status badges + spacing) .padding(.leading, 16)
} }
// Input area // Drag-to-resize handle
dragHandle
// Input row
HStack(alignment: .bottom, spacing: 12) { HStack(alignment: .bottom, spacing: 12) {
// Status indicators // Text input with globe toggle in bottom-left corner
HStack(spacing: 6) {
if let mcp = mcpStatus {
StatusBadge(text: mcp, color: .blue)
}
if onlineMode {
StatusBadge(text: "🌐", color: .green)
}
}
.frame(width: 80, alignment: .leading)
// Text input
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
// Placeholder
if text.isEmpty { if text.isEmpty {
Text("Type a message or / for commands...") Text("Type a message or / for commands...")
.font(.system(size: settings.inputTextSize)) .font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiSecondary) .foregroundColor(.oaiSecondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.top, 10)
.allowsHitTesting(false)
} }
TextEditor(text: $text) // Editor fills the fixed-height box, bottom area reserved for globe
.font(.system(size: settings.inputTextSize)) NativeTextEditor(
.foregroundColor(.oaiPrimary) text: $text,
.scrollContentBackground(.hidden) font: .systemFont(ofSize: settings.inputTextSize),
.frame(minHeight: 44, maxHeight: 120) textColor: NSColor(Color.oaiPrimary),
.padding(.horizontal, 8) isFocused: isInputFocused,
.padding(.vertical, 6) onReturn: {
.focused($isInputFocused)
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
selectedSuggestionIndex = 0
}
#if os(macOS)
.onKeyPress(.upArrow) {
// Navigate command dropdown
if showCommandDropdown && selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
return .handled
}
return .ignored
}
.onKeyPress(.downArrow) {
// Navigate command dropdown
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
return .handled
}
}
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
}
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 showCommandDropdown { if showCommandDropdown {
let suggestions = CommandSuggestionsView.filteredCommands(for: text) let suggestions = CommandSuggestionsView.filteredCommands(for: text)
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count { if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
selectCommand(suggestions[selectedSuggestionIndex].command) selectCommand(suggestions[selectedSuggestionIndex].command)
return .handled return true
} }
} }
// Return (plain or with Cmd): send message if !text.isEmpty { onSend(); return true }
if !text.isEmpty { return true
onSend() },
return .handled onEscape: {
if showCommandDropdown { showCommandDropdown = false; return true }
if isGenerating { onCancel(); return true }
return false
},
onUpArrow: {
if showCommandDropdown && selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1; return true
} }
// Empty text: do nothing return false
return .handled },
onDownArrow: {
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1; return true
} }
#endif
} }
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 {
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) .background(Color.oaiSurface)
.cornerRadius(8) .cornerRadius(8)
.overlay( .overlay(
@@ -167,10 +166,9 @@ struct InputBar: View {
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1) .stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
) )
// Action buttons // Send / stop + attach buttons
VStack(spacing: 8) { VStack(spacing: 8) {
#if os(macOS) #if os(macOS)
// File attach button
Button(action: pickFile) { Button(action: pickFile) {
Image(systemName: "paperclip") Image(systemName: "paperclip")
.font(.title2) .font(.title2)
@@ -209,21 +207,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) { private func selectCommand(_ command: String) {
showCommandDropdown = false showCommandDropdown = false
if Self.immediateCommands.contains(command) { if Self.immediateCommands.contains(command) {
// Execute immediately
text = command text = command
onSend() onSend()
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) { } else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
if shortcut.needsInput { text = shortcut.needsInput ? command + " " : command
text = command + " " if !shortcut.needsInput { onSend() }
} else { } else {
text = command
onSend()
}
} else {
// Put in input for user to complete
text = command + " " text = command + " "
} }
} }
@@ -235,36 +259,14 @@ struct InputBar: View {
panel.canChooseDirectories = false panel.canChooseDirectories = false
panel.canChooseFiles = true panel.canChooseFiles = true
panel.message = "Select files to attach" panel.message = "Select files to attach"
guard panel.runModal() == .OK else { return } guard panel.runModal() == .OK else { return }
let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ")
let paths = panel.urls.map { $0.path } text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText
// Use @<path> format (angle brackets) to safely handle paths with spaces
let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ")
if text.isEmpty {
text = attachmentText + " "
} else {
text += " " + attachmentText
}
} }
#endif #endif
} }
struct StatusBadge: View { // MARK: - Command suggestions
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)
}
}
struct CommandSuggestionsView: View { struct CommandSuggestionsView: View {
let searchText: String let searchText: String
@@ -304,10 +306,9 @@ struct CommandSuggestionsView: View {
] ]
static func allCommands() -> [(command: String, description: LocalizedStringKey)] { 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)")) (s.command, LocalizedStringKey("\(s.description)"))
} } + builtInCommands
return builtInCommands + shortcuts
} }
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] { static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
@@ -344,26 +345,20 @@ struct CommandSuggestionsView: View {
.id(suggestion.command) .id(suggestion.command)
if index < suggestions.count - 1 { if index < suggestions.count - 1 {
Divider() Divider().background(Color.oaiBorder)
.background(Color.oaiBorder)
} }
} }
} }
} }
.onChange(of: selectedIndex) { .onChange(of: selectedIndex) {
if selectedIndex < suggestions.count { if selectedIndex < suggestions.count {
withAnimation { withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) }
proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
}
} }
} }
} }
.background(Color.oaiSurface) .background(Color.oaiSurface)
.cornerRadius(8) .cornerRadius(8)
.overlay( .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1))
RoundedRectangle(cornerRadius: 8)
.stroke(Color.oaiBorder, lineWidth: 1)
)
} }
} }
@@ -373,10 +368,10 @@ struct CommandSuggestionsView: View {
InputBar( InputBar(
text: .constant(""), text: .constant(""),
isGenerating: false, isGenerating: false,
mcpStatus: "📁 Files",
onlineMode: true, onlineMode: true,
onSend: {}, onSend: {},
onCancel: {} onCancel: {},
onToggleOnline: {}
) )
} }
.background(Color.oaiBackground) .background(Color.oaiBackground)
+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
}
}
+216
View File
@@ -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 <https://www.gnu.org/licenses/>.
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)
}
@@ -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 semanticResults: [Conversation] = []
@State private var isSearching = false @State private var isSearching = false
@State private var selectedIndex: Int = 0 @State private var selectedIndex: Int = 0
@State private var showCombineSheet = false
@FocusState private var searchFocused: Bool @FocusState private var searchFocused: Bool
private let settings = SettingsService.shared private let settings = SettingsService.shared
var onLoad: ((Conversation) -> Void)? var onLoad: ((Conversation) -> Void)?
@@ -70,6 +71,18 @@ struct ConversationListView: View {
} }
.buttonStyle(.plain) .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 { if !selectedConversations.isEmpty {
Button(role: .destructive) { Button(role: .destructive) {
deleteSelected() deleteSelected()
@@ -298,6 +311,16 @@ struct ConversationListView: View {
searchFocused = true searchFocused = true
} }
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600) .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() { private func loadConversations() {
+12 -1
View File
@@ -30,6 +30,7 @@ struct ModelInfoView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Bindable private var settings = SettingsService.shared @Bindable private var settings = SettingsService.shared
@State private var isDescriptionExpanded = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -78,8 +79,18 @@ struct ModelInfoView: View {
Text(desc) Text(desc)
.font(.body) .font(.body)
.foregroundColor(.primary) .foregroundColor(.primary)
.fixedSize(horizontal: false, vertical: true) .lineLimit(isDescriptionExpanded ? nil : 4)
.textSelection(.enabled) .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) .padding(.leading, 4)
} }
+36
View File
@@ -92,6 +92,8 @@ struct oAIApp: App {
CommandGroup(after: .newItem) { CommandGroup(after: .newItem) {
Button("Open Chat…") { chatViewModel.showConversations = true } Button("Open Chat…") { chatViewModel.showConversations = true }
.keyboardShortcut("o", modifiers: .command) .keyboardShortcut("o", modifiers: .command)
Button("Search Conversations") { chatViewModel.showConversations = true }
.keyboardShortcut("l", modifiers: .command)
} }
CommandGroup(replacing: .saveItem) { CommandGroup(replacing: .saveItem) {
@@ -113,10 +115,44 @@ struct oAIApp: App {
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) .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 // Help menu
CommandGroup(replacing: .help) { CommandGroup(replacing: .help) {
Button("oAI Help") { openHelp() } Button("oAI Help") { openHelp() }
.keyboardShortcut("?", modifiers: .command) .keyboardShortcut("?", modifiers: .command)
Divider()
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
UpdateCheckService.shared.checkForUpdatesManually()
}
.disabled(UpdateCheckService.shared.isCheckingManually)
} }
} }
#endif #endif