Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b99a6f81c | |||
| a793fdacc4 | |||
| 414cf8cb8c | |||
| e7c7b9b5c6 | |||
| 87535dc2ad | |||
| 3dff8a8c8e | |||
| 00dccd648c | |||
| 92e393ab03 | |||
| 22f745762f | |||
| b3bb7c4a59 | |||
| ef1c05c13b | |||
| f63226b2cc |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "2620"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
|
|
||||||
BuildableName = "oAI.app"
|
|
||||||
BlueprintName = "oAI"
|
|
||||||
ReferencedContainer = "container:oAI.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
language = "en"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
|
|
||||||
BuildableName = "oAI.app"
|
|
||||||
BlueprintName = "oAI"
|
|
||||||
ReferencedContainer = "container:oAI.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
|
|
||||||
BuildableName = "oAI.app"
|
|
||||||
BlueprintName = "oAI"
|
|
||||||
ReferencedContainer = "container:oAI.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xCA",
|
||||||
|
"green" : "0x7A",
|
||||||
|
"red" : "0x0A"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] = [],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -58,25 +58,17 @@ struct Settings: Codable {
|
|||||||
case anthropic
|
case anthropic
|
||||||
case openai
|
case openai
|
||||||
case ollama
|
case ollama
|
||||||
case appleOnDevice = "apple_on_device"
|
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
rawValue.capitalized
|
||||||
case .openrouter: return "OpenRouter"
|
|
||||||
case .anthropic: return "Anthropic"
|
|
||||||
case .openai: return "OpenAI"
|
|
||||||
case .ollama: return "Ollama"
|
|
||||||
case .appleOnDevice: return "Apple Intelligence"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconName: String {
|
var iconName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .openrouter: return "network"
|
case .openrouter: return "network"
|
||||||
case .anthropic: return "brain"
|
case .anthropic: return "brain"
|
||||||
case .openai: return "sparkles"
|
case .openai: return "sparkles"
|
||||||
case .ollama: return "server.rack"
|
case .ollama: return "server.rack"
|
||||||
case .appleOnDevice: return "apple.logo"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
//
|
|
||||||
// AppleFoundationProvider.swift
|
|
||||||
// oAI
|
|
||||||
//
|
|
||||||
// Apple Foundation Models provider (on-device Apple Intelligence)
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
//
|
|
||||||
// This file is part of oAI.
|
|
||||||
//
|
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as
|
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
|
||||||
// License, or (at your option) any later version.
|
|
||||||
//
|
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
|
||||||
// Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import FoundationModels
|
|
||||||
import os
|
|
||||||
|
|
||||||
final class AppleFoundationProvider: AIProvider {
|
|
||||||
let name = "Apple Intelligence"
|
|
||||||
|
|
||||||
let capabilities = ProviderCapabilities(
|
|
||||||
supportsStreaming: true,
|
|
||||||
supportsVision: false,
|
|
||||||
supportsTools: false,
|
|
||||||
supportsOnlineSearch: false,
|
|
||||||
maxContextLength: 4096
|
|
||||||
)
|
|
||||||
|
|
||||||
// MARK: - Models
|
|
||||||
|
|
||||||
func listModels() async throws -> [ModelInfo] {
|
|
||||||
[
|
|
||||||
ModelInfo(
|
|
||||||
id: "apple-on-device",
|
|
||||||
name: "Apple On-Device",
|
|
||||||
description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window.",
|
|
||||||
contextLength: 4096,
|
|
||||||
pricing: ModelInfo.Pricing(prompt: 0, completion: 0),
|
|
||||||
capabilities: ModelInfo.ModelCapabilities(
|
|
||||||
vision: false,
|
|
||||||
tools: false,
|
|
||||||
online: false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
func getModel(_ id: String) async throws -> ModelInfo? {
|
|
||||||
try await listModels().first { $0.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCredits() async throws -> Credits? { nil }
|
|
||||||
|
|
||||||
// MARK: - Streaming chat
|
|
||||||
|
|
||||||
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
|
|
||||||
AsyncThrowingStream { continuation in
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let session = try self.makeSession(for: request)
|
|
||||||
let prompt = self.lastUserMessage(from: request)
|
|
||||||
|
|
||||||
// streamResponse(to: String) → ResponseStream<String>
|
|
||||||
// Each snapshot.content is the full accumulated text so far (snapshot model).
|
|
||||||
// We compute deltas by comparing each snapshot to the previous.
|
|
||||||
let stream = session.streamResponse(to: prompt)
|
|
||||||
var lastContent = ""
|
|
||||||
|
|
||||||
for try await snapshot in stream {
|
|
||||||
let current = snapshot.content
|
|
||||||
if current.count > lastContent.count {
|
|
||||||
let delta = String(current.dropFirst(lastContent.count))
|
|
||||||
continuation.yield(StreamChunk(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
model: request.model,
|
|
||||||
delta: StreamChunk.Delta(content: delta, role: "assistant"),
|
|
||||||
finishReason: nil,
|
|
||||||
usage: nil
|
|
||||||
))
|
|
||||||
lastContent = current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation.yield(StreamChunk(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
model: request.model,
|
|
||||||
delta: StreamChunk.Delta(content: nil, role: nil),
|
|
||||||
finishReason: "stop",
|
|
||||||
usage: nil
|
|
||||||
))
|
|
||||||
continuation.finish()
|
|
||||||
|
|
||||||
} catch let genError as LanguageModelSession.GenerationError {
|
|
||||||
continuation.finish(throwing: self.mapGenerationError(genError))
|
|
||||||
} catch {
|
|
||||||
continuation.finish(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Non-streaming chat
|
|
||||||
|
|
||||||
func chat(request: ChatRequest) async throws -> ChatResponse {
|
|
||||||
let session = try makeSession(for: request)
|
|
||||||
let prompt = lastUserMessage(from: request)
|
|
||||||
let response: LanguageModelSession.Response<String> = try await session.respond(to: prompt)
|
|
||||||
return ChatResponse(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
model: request.model,
|
|
||||||
content: response.content,
|
|
||||||
role: "assistant",
|
|
||||||
finishReason: "stop",
|
|
||||||
usage: nil,
|
|
||||||
created: Date()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tool messages (not supported in Phase 1)
|
|
||||||
|
|
||||||
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
|
|
||||||
throw ProviderError.unknown("Tool calling requires Apple Foundation Models Phase 3.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session construction
|
|
||||||
|
|
||||||
private func makeSession(for request: ChatRequest) throws -> LanguageModelSession {
|
|
||||||
guard case .available = SystemLanguageModel.default.availability else {
|
|
||||||
throw availabilityError()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build instructions: system prompt + prior conversation turns as formatted text.
|
|
||||||
// Foundation Models sessions don't accept a message array — we inject history inline.
|
|
||||||
var instructions = request.systemPrompt ?? ""
|
|
||||||
let priorMessages = request.messages.dropLast().filter { $0.role != .system }
|
|
||||||
|
|
||||||
if !priorMessages.isEmpty {
|
|
||||||
let history = priorMessages
|
|
||||||
.map { m -> String in
|
|
||||||
let label = m.role == .user ? "User" : "Assistant"
|
|
||||||
return "\(label): \(m.content)"
|
|
||||||
}
|
|
||||||
.joined(separator: "\n")
|
|
||||||
instructions += "\n\nConversation so far:\n\(history)\n\nContinue from here."
|
|
||||||
}
|
|
||||||
|
|
||||||
return instructions.isEmpty
|
|
||||||
? LanguageModelSession()
|
|
||||||
: LanguageModelSession(instructions: instructions)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func lastUserMessage(from request: ChatRequest) -> String {
|
|
||||||
request.messages.last(where: { $0.role == .user })?.content ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Error mapping
|
|
||||||
|
|
||||||
private func availabilityError() -> Error {
|
|
||||||
switch SystemLanguageModel.default.availability {
|
|
||||||
case .unavailable(.deviceNotEligible):
|
|
||||||
return ProviderError.unknown("This Mac doesn't support Apple Intelligence. Apple Silicon is required.")
|
|
||||||
case .unavailable(.appleIntelligenceNotEnabled):
|
|
||||||
return ProviderError.unknown("Apple Intelligence is not enabled. Open System Settings → Apple Intelligence to turn it on.")
|
|
||||||
case .unavailable(.modelNotReady):
|
|
||||||
return ProviderError.unknown("Apple Intelligence model is still downloading. Please wait and try again.")
|
|
||||||
default:
|
|
||||||
return ProviderError.unknown("Apple Intelligence is not available on this device.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mapGenerationError(_ error: LanguageModelSession.GenerationError) -> Error {
|
|
||||||
switch error {
|
|
||||||
case .exceededContextWindowSize:
|
|
||||||
return ProviderError.unknown("Apple Intelligence context limit exceeded (4,096 tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.")
|
|
||||||
case .rateLimited:
|
|
||||||
return ProviderError.rateLimitExceeded
|
|
||||||
case .guardrailViolation:
|
|
||||||
return ProviderError.unknown("Apple Intelligence declined to respond to this message.")
|
|
||||||
default:
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,7 +48,12 @@ 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
|
||||||
let content: MessageContent
|
let content: MessageContent
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -69,9 +69,6 @@ class ProviderRegistry {
|
|||||||
|
|
||||||
case .ollama:
|
case .ollama:
|
||||||
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
|
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
|
||||||
|
|
||||||
case .appleOnDevice:
|
|
||||||
provider = AppleFoundationProvider()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache and return
|
// Cache and return
|
||||||
@@ -109,8 +106,6 @@ class ProviderRegistry {
|
|||||||
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
||||||
case .ollama:
|
case .ollama:
|
||||||
return settings.ollamaConfigured
|
return settings.ollamaConfigured
|
||||||
case .appleOnDevice:
|
|
||||||
return true // no API key needed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,12 +31,11 @@ import IOKit
|
|||||||
class EncryptionService {
|
class EncryptionService {
|
||||||
nonisolated 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
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -60,11 +60,10 @@ extension Color {
|
|||||||
|
|
||||||
static func providerColor(_ provider: Settings.Provider) -> Color {
|
static func providerColor(_ provider: Settings.Provider) -> Color {
|
||||||
switch provider {
|
switch provider {
|
||||||
case .openrouter: return Color(hex: "#7c3aed") // Purple
|
case .openrouter: return Color(hex: "#7c3aed") // Purple
|
||||||
case .anthropic: return Color(hex: "#d4895a") // Orange
|
case .anthropic: return Color(hex: "#d4895a") // Orange
|
||||||
case .openai: return Color(hex: "#10a37f") // Green
|
case .openai: return Color(hex: "#10a37f") // Green
|
||||||
case .ollama: return Color(hex: "#ffffff") // White
|
case .ollama: return Color(hex: "#ffffff") // White
|
||||||
case .appleOnDevice: return Color(hex: "#636366") // Apple grey
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -424,8 +424,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
|
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
|
||||||
|
|
||||||
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
||||||
// Apple Foundation Models
|
|
||||||
if modelId.hasPrefix("apple-") { return .appleOnDevice }
|
|
||||||
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
||||||
if modelId.contains("/") { return .openrouter }
|
if modelId.contains("/") { return .openrouter }
|
||||||
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
||||||
@@ -936,10 +934,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
messages[index].tokens = usage.completionTokens
|
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)
|
||||||
}
|
}
|
||||||
@@ -1003,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)
|
||||||
}
|
}
|
||||||
@@ -1531,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
|
||||||
}
|
}
|
||||||
@@ -2182,6 +2171,18 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cost for one response's usage, accounting for Anthropic-style prompt-cache
|
||||||
|
/// pricing when present: cache writes cost 1.25x the base input rate, cache
|
||||||
|
/// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder —
|
||||||
|
/// it does not need cache tokens subtracted from it.
|
||||||
|
private func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
|
||||||
|
let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
|
||||||
|
let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000
|
||||||
|
let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000
|
||||||
|
let outputCost = Double(usage.completionTokens) * pricing.completion / 1_000_000
|
||||||
|
return inputCost + cacheReadCost + cacheWriteCost + outputCost
|
||||||
|
}
|
||||||
|
|
||||||
/// Summarize a chunk of messages into a concise summary
|
/// 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),
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ struct ChatView: View {
|
|||||||
provider: viewModel.currentProvider,
|
provider: viewModel.currentProvider,
|
||||||
model: viewModel.selectedModel,
|
model: viewModel.selectedModel,
|
||||||
onModelSelect: onModelSelect,
|
onModelSelect: onModelSelect,
|
||||||
onProviderChange: onProviderChange
|
onProviderChange: onProviderChange,
|
||||||
|
conversationName: viewModel.currentConversationName,
|
||||||
|
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
||||||
|
onQuickSave: viewModel.quickSave
|
||||||
)
|
)
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
|
|||||||
@@ -93,15 +93,6 @@ struct FooterView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Save indicator (only when chat has messages)
|
|
||||||
if stats.messageCount > 0 {
|
|
||||||
SaveIndicator(
|
|
||||||
conversationName: conversationName,
|
|
||||||
hasUnsavedChanges: hasUnsavedChanges,
|
|
||||||
onSave: onQuickSave
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update available badge (shows only when an update exists — no version number)
|
// Update available badge (shows only when an update exists — no version number)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
UpdateBadge()
|
UpdateBadge()
|
||||||
|
|||||||
+125
-97
@@ -31,109 +31,24 @@ struct HeaderView: View {
|
|||||||
let model: ModelInfo?
|
let model: ModelInfo?
|
||||||
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
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
ZStack {
|
||||||
// Provider picker dropdown — only shows configured providers
|
// Left: provider + model + star
|
||||||
Menu {
|
HStack(spacing: 12) {
|
||||||
ForEach(registry.configuredProviders, id: \.self) { p in
|
providerMenu
|
||||||
Button {
|
modelButton
|
||||||
onProviderChange(p)
|
starButton
|
||||||
} label: {
|
Spacer()
|
||||||
HStack {
|
|
||||||
Image(systemName: p.iconName)
|
|
||||||
Text(p.displayName)
|
|
||||||
if p == provider {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: provider.iconName)
|
|
||||||
.font(.system(size: settings.guiTextSize - 2))
|
|
||||||
Text(provider.displayName)
|
|
||||||
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
|
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
|
||||||
.font(.system(size: 8))
|
|
||||||
.opacity(0.7)
|
|
||||||
}
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.providerColor(provider))
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
.menuStyle(.borderlessButton)
|
|
||||||
.fixedSize()
|
|
||||||
.help("Switch provider")
|
|
||||||
|
|
||||||
// Model name (clickable → model selector)
|
|
||||||
Button(action: onModelSelect) {
|
|
||||||
if let model = model {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text(model.name)
|
|
||||||
.font(.system(size: settings.guiTextSize, weight: .medium))
|
|
||||||
.foregroundColor(.oaiPrimary)
|
|
||||||
|
|
||||||
// Capability badges
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
if model.capabilities.vision {
|
|
||||||
Image(systemName: "eye")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
if model.capabilities.tools {
|
|
||||||
Image(systemName: "wrench")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
if model.capabilities.online {
|
|
||||||
Image(systemName: "globe")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
if model.capabilities.imageGeneration {
|
|
||||||
Image(systemName: "paintbrush")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("No model selected")
|
|
||||||
.font(.system(size: settings.guiTextSize))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help("Select model")
|
|
||||||
|
|
||||||
// Favourite star
|
|
||||||
if let model = model {
|
|
||||||
let isFav = settings.favoriteModelIds.contains(model.id)
|
|
||||||
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
|
||||||
Image(systemName: isFav ? "star.fill" : "star")
|
|
||||||
.font(.system(size: settings.guiTextSize - 3))
|
|
||||||
.foregroundColor(isFav ? .yellow : .oaiSecondary)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
// Center: conversation title (macOS document-title style)
|
||||||
|
conversationTitle
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -145,6 +60,119 @@ struct HeaderView: View {
|
|||||||
alignment: .bottom
|
alignment: .bottom
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Conversation title (center)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var conversationTitle: some View {
|
||||||
|
if let name = conversationName {
|
||||||
|
Button(action: { if hasUnsavedChanges { onQuickSave?() } }) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
if hasUnsavedChanges {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.orange)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
}
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
|
||||||
|
.foregroundColor(.oaiPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!hasUnsavedChanges)
|
||||||
|
.help(hasUnsavedChanges ? "Unsaved changes — click to save" : "Saved")
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subviews (extracted so ZStack stays readable)
|
||||||
|
|
||||||
|
private var providerMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(registry.configuredProviders, id: \.self) { p in
|
||||||
|
Button {
|
||||||
|
onProviderChange(p)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: p.iconName)
|
||||||
|
Text(p.displayName)
|
||||||
|
if p == provider { Image(systemName: "checkmark") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: provider.iconName)
|
||||||
|
.font(.system(size: settings.guiTextSize - 2))
|
||||||
|
Text(provider.displayName)
|
||||||
|
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.opacity(0.7)
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.providerColor(provider))
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.fixedSize()
|
||||||
|
.help("Switch provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelButton: some View {
|
||||||
|
Button(action: onModelSelect) {
|
||||||
|
if let model = model {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(model.name)
|
||||||
|
.font(.system(size: settings.guiTextSize, weight: .medium))
|
||||||
|
.foregroundColor(.oaiPrimary)
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
if model.capabilities.vision {
|
||||||
|
Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
if model.capabilities.tools {
|
||||||
|
Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
if model.capabilities.online {
|
||||||
|
Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
if model.capabilities.imageGeneration {
|
||||||
|
Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("No model selected")
|
||||||
|
.font(.system(size: settings.guiTextSize))
|
||||||
|
.foregroundColor(.oaiSecondary)
|
||||||
|
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Select model")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var starButton: some View {
|
||||||
|
if let model = model {
|
||||||
|
let isFav = settings.favoriteModelIds.contains(model.id)
|
||||||
|
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
||||||
|
Image(systemName: isFav ? "star.fill" : "star")
|
||||||
|
.font(.system(size: settings.guiTextSize - 3))
|
||||||
|
.foregroundColor(isFav ? .yellow : .oaiSecondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Status Pills (used by SidebarView)
|
// MARK: - Status Pills (used by SidebarView)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct InputBar: View {
|
|||||||
|
|
||||||
@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 minInputHeight: CGFloat = 56
|
||||||
private static let maxInputHeight: CGFloat = 320
|
private static let maxInputHeight: CGFloat = 320
|
||||||
@@ -95,55 +95,50 @@ struct InputBar: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Editor — fills the fixed-height box, bottom area reserved for globe
|
// Editor — fills the fixed-height box, bottom area reserved for globe
|
||||||
TextEditor(text: $text)
|
NativeTextEditor(
|
||||||
.font(.system(size: settings.inputTextSize))
|
text: $text,
|
||||||
.foregroundColor(.oaiPrimary)
|
font: .systemFont(ofSize: settings.inputTextSize),
|
||||||
.scrollContentBackground(.hidden)
|
textColor: NSColor(Color.oaiPrimary),
|
||||||
.padding(.horizontal, 8)
|
isFocused: isInputFocused,
|
||||||
.padding(.top, 6)
|
onReturn: {
|
||||||
.padding(.bottom, 30)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.focused($isInputFocused)
|
|
||||||
.onChange(of: text) {
|
|
||||||
showCommandDropdown = text.hasPrefix("/")
|
|
||||||
selectedSuggestionIndex = 0
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
|
||||||
.onKeyPress(.upArrow) {
|
|
||||||
if showCommandDropdown && selectedSuggestionIndex > 0 {
|
|
||||||
selectedSuggestionIndex -= 1
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
.onKeyPress(.downArrow) {
|
|
||||||
if showCommandDropdown {
|
|
||||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
|
||||||
if selectedSuggestionIndex < count - 1 {
|
|
||||||
selectedSuggestionIndex += 1
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
.onKeyPress(.escape) {
|
|
||||||
if showCommandDropdown { showCommandDropdown = false; return .handled }
|
|
||||||
if isGenerating { onCancel(); return .handled }
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
.onKeyPress(.return, phases: .down) { press in
|
|
||||||
if press.modifiers.contains(.shift) { return .ignored }
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !text.isEmpty { onSend(); return .handled }
|
if !text.isEmpty { onSend(); return true }
|
||||||
return .handled
|
return true
|
||||||
}
|
},
|
||||||
#endif
|
onEscape: {
|
||||||
|
if showCommandDropdown { showCommandDropdown = false; return true }
|
||||||
|
if isGenerating { onCancel(); return true }
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onUpArrow: {
|
||||||
|
if showCommandDropdown && selectedSuggestionIndex > 0 {
|
||||||
|
selectedSuggestionIndex -= 1; return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onDownArrow: {
|
||||||
|
if showCommandDropdown {
|
||||||
|
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||||
|
if selectedSuggestionIndex < count - 1 {
|
||||||
|
selectedSuggestionIndex += 1; return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onFocusChange: { focused in isInputFocused = focused }
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onChange(of: text) {
|
||||||
|
showCommandDropdown = text.hasPrefix("/")
|
||||||
|
selectedSuggestionIndex = 0
|
||||||
|
}
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
// Online / offline toggle — bottom-left of the text box
|
// Online / offline toggle — bottom-left of the text box
|
||||||
VStack {
|
VStack {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//
|
||||||
|
// NativeTextEditor.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// NSViewRepresentable text editor with correct Enter-key semantics:
|
||||||
|
// plain Enter → send, Shift+Enter or Cmd+Enter → newline.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
struct NativeTextEditor: NSViewRepresentable {
|
||||||
|
@Binding var text: String
|
||||||
|
var font: NSFont
|
||||||
|
var textColor: NSColor
|
||||||
|
var isFocused: Bool
|
||||||
|
|
||||||
|
/// Plain Enter (no modifiers). Return true if the event was consumed.
|
||||||
|
var onReturn: () -> Bool
|
||||||
|
/// Escape key. Return true if consumed.
|
||||||
|
var onEscape: () -> Bool
|
||||||
|
/// Up arrow. Return true if consumed.
|
||||||
|
var onUpArrow: () -> Bool
|
||||||
|
/// Down arrow. Return true if consumed.
|
||||||
|
var onDownArrow: () -> Bool
|
||||||
|
/// Called when the view gains or loses first-responder status.
|
||||||
|
var onFocusChange: (Bool) -> Void
|
||||||
|
|
||||||
|
// MARK: - NSViewRepresentable
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSScrollView {
|
||||||
|
let scrollView = NSScrollView()
|
||||||
|
scrollView.hasVerticalScroller = false
|
||||||
|
scrollView.hasHorizontalScroller = false
|
||||||
|
scrollView.drawsBackground = false
|
||||||
|
scrollView.borderType = .noBorder
|
||||||
|
|
||||||
|
let tv = context.coordinator.textView
|
||||||
|
tv.delegate = context.coordinator
|
||||||
|
tv.isEditable = true
|
||||||
|
tv.isRichText = false
|
||||||
|
tv.drawsBackground = false
|
||||||
|
tv.backgroundColor = .clear
|
||||||
|
tv.isAutomaticQuoteSubstitutionEnabled = false
|
||||||
|
tv.isAutomaticDashSubstitutionEnabled = false
|
||||||
|
tv.isAutomaticSpellingCorrectionEnabled = true
|
||||||
|
tv.isContinuousSpellCheckingEnabled = true
|
||||||
|
tv.allowsUndo = true
|
||||||
|
tv.isVerticallyResizable = true
|
||||||
|
tv.isHorizontallyResizable = false
|
||||||
|
tv.autoresizingMask = [.width]
|
||||||
|
tv.textContainer?.widthTracksTextView = true
|
||||||
|
tv.textContainerInset = NSSize(width: 8, height: 6)
|
||||||
|
|
||||||
|
scrollView.documentView = tv
|
||||||
|
return scrollView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||||
|
let tv = context.coordinator.textView
|
||||||
|
let coord = context.coordinator
|
||||||
|
|
||||||
|
// Update text only when it differs (avoids caret-jumping on every keystroke)
|
||||||
|
if tv.string != text {
|
||||||
|
let sel = tv.selectedRanges
|
||||||
|
tv.string = text
|
||||||
|
let len = (tv.string as NSString).length
|
||||||
|
tv.selectedRanges = sel.map { v in
|
||||||
|
let r = v.rangeValue
|
||||||
|
let loc = min(r.location, len)
|
||||||
|
let length = min(r.length, max(0, len - loc))
|
||||||
|
return NSValue(range: NSRange(location: loc, length: length))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tv.font != font { tv.font = font }
|
||||||
|
if tv.textColor != textColor { tv.textColor = textColor }
|
||||||
|
|
||||||
|
// Keep coordinator callbacks current with each SwiftUI render
|
||||||
|
coord.textBinding = $text
|
||||||
|
coord.onReturn = onReturn
|
||||||
|
coord.onEscape = onEscape
|
||||||
|
coord.onUpArrow = onUpArrow
|
||||||
|
coord.onDownArrow = onDownArrow
|
||||||
|
coord.onFocusChange = onFocusChange
|
||||||
|
|
||||||
|
if isFocused {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let window = tv.window, window.firstResponder !== tv else { return }
|
||||||
|
window.makeFirstResponder(tv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
|
||||||
|
// MARK: - Coordinator
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||||
|
let textView = KeyableNSTextView()
|
||||||
|
|
||||||
|
// Updated on every SwiftUI render via updateNSView
|
||||||
|
var textBinding: Binding<String>?
|
||||||
|
var onReturn: () -> Bool = { false }
|
||||||
|
var onEscape: () -> Bool = { false }
|
||||||
|
var onUpArrow: () -> Bool = { false }
|
||||||
|
var onDownArrow: () -> Bool = { false }
|
||||||
|
var onFocusChange: (Bool) -> Void = { _ in }
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
textView.coordinator = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func textDidChange(_ notification: Notification) {
|
||||||
|
guard let tv = notification.object as? NSTextView else { return }
|
||||||
|
textBinding?.wrappedValue = tv.string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - KeyableNSTextView
|
||||||
|
|
||||||
|
/// NSTextView that routes Return / Escape / arrow keys to the SwiftUI
|
||||||
|
/// coordinator before the AppKit default handling runs.
|
||||||
|
final class KeyableNSTextView: NSTextView {
|
||||||
|
weak var coordinator: NativeTextEditor.Coordinator?
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
guard let coord = coordinator else { super.keyDown(with: event); return }
|
||||||
|
|
||||||
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||||
|
let shift = flags.contains(.shift)
|
||||||
|
let cmd = flags.contains(.command)
|
||||||
|
|
||||||
|
switch event.keyCode {
|
||||||
|
case 36: // Return
|
||||||
|
if shift || cmd {
|
||||||
|
// Shift+Enter or Cmd+Enter → literal newline
|
||||||
|
insertNewlineIgnoringFieldEditor(nil)
|
||||||
|
} else {
|
||||||
|
// Plain Enter → let SwiftUI decide (send or select dropdown item)
|
||||||
|
if !coord.onReturn() {
|
||||||
|
insertNewlineIgnoringFieldEditor(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 53: // Escape
|
||||||
|
if !coord.onEscape() { super.keyDown(with: event) }
|
||||||
|
case 126: // Up arrow
|
||||||
|
if !coord.onUpArrow() { super.keyDown(with: event) }
|
||||||
|
case 125: // Down arrow
|
||||||
|
if !coord.onDownArrow() { super.keyDown(with: event) }
|
||||||
|
default:
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func becomeFirstResponder() -> Bool {
|
||||||
|
let ok = super.becomeFirstResponder()
|
||||||
|
if ok { coordinator?.onFocusChange(true) }
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resignFirstResponder() -> Bool {
|
||||||
|
let ok = super.resignFirstResponder()
|
||||||
|
if ok { coordinator?.onFocusChange(false) }
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//
|
||||||
|
// CombineConversationsSheet.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Combine 2+ saved conversations into one, optionally using AI to merge content
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as
|
||||||
|
// published by the Free Software Foundation, either version 3 of the
|
||||||
|
// License, or (at your option) any later version.
|
||||||
|
//
|
||||||
|
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
||||||
|
// Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public
|
||||||
|
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CombineConversationsSheet: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
let conversations: [Conversation]
|
||||||
|
var onCompleted: (Conversation) -> Void
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var mode: CombineMode = .simple
|
||||||
|
@State private var deleteOriginals = false
|
||||||
|
@State private var isProcessing = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
private let settings = SettingsService.shared
|
||||||
|
|
||||||
|
init(conversations: [Conversation], onCompleted: @escaping (Conversation) -> Void) {
|
||||||
|
self.conversations = conversations
|
||||||
|
self.onCompleted = onCompleted
|
||||||
|
let joined = conversations.map(\.name).joined(separator: " + ")
|
||||||
|
_name = State(initialValue: String(joined.prefix(80)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var defaultModelLabel: String? {
|
||||||
|
guard let model = settings.defaultModel, !model.isEmpty else { return nil }
|
||||||
|
return "\(settings.defaultProvider.displayName) / \(model)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isValid: Bool {
|
||||||
|
!name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& conversations.count >= 2
|
||||||
|
&& (mode == .simple || defaultModelLabel != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Combine Conversations")
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
Spacer()
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.title2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.keyboardShortcut(.escape, modifiers: [])
|
||||||
|
.disabled(isProcessing)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Combining \(conversations.count) conversations").font(.system(size: 13, weight: .semibold))
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(conversations) { conversation in
|
||||||
|
Label("\(conversation.name) (\(conversation.messageCount) messages)", systemImage: "bubble.left.and.bubble.right")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("New conversation name").font(.system(size: 13, weight: .semibold))
|
||||||
|
TextField("Name", text: $name)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(isProcessing)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Merge method").font(.system(size: 13, weight: .semibold))
|
||||||
|
Picker("", selection: $mode) {
|
||||||
|
Text("Simple Merge").tag(CombineMode.simple)
|
||||||
|
Text("AI-Assisted Merge").tag(CombineMode.ai)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(isProcessing)
|
||||||
|
|
||||||
|
if mode == .simple {
|
||||||
|
Text("Messages from all selected conversations are combined in chronological order.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("A model reads all the source messages and rewrites them into one coherent, de-duplicated conversation.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
if let label = defaultModelLabel {
|
||||||
|
Label("Uses your default model: \(label)", systemImage: "cpu")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Label("No default model configured — set one in Settings → General.", systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Delete original conversations after combining", isOn: $deleteOriginals)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.disabled(isProcessing)
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "xmark.octagon.fill").foregroundStyle(.red)
|
||||||
|
Text(errorMessage).font(.caption)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24).padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(isProcessing)
|
||||||
|
Spacer()
|
||||||
|
if isProcessing {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
Text("Combining…").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Button("Combine") {
|
||||||
|
combine()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!isValid || isProcessing)
|
||||||
|
.keyboardShortcut(.return, modifiers: [.command])
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24).padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 520, idealWidth: 560, minHeight: 460, idealHeight: 520)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func combine() {
|
||||||
|
isProcessing = true
|
||||||
|
errorMessage = nil
|
||||||
|
let ids = conversations.map(\.id)
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||||
|
let selectedMode = mode
|
||||||
|
let shouldDeleteOriginals = deleteOriginals
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let newConversation = try await ConversationMergeService.merge(
|
||||||
|
conversationIds: ids,
|
||||||
|
name: trimmedName,
|
||||||
|
mode: selectedMode,
|
||||||
|
deleteOriginals: shouldDeleteOriginals
|
||||||
|
)
|
||||||
|
await MainActor.run {
|
||||||
|
isProcessing = false
|
||||||
|
onCompleted(newConversation)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isProcessing = false
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ struct ConversationListView: View {
|
|||||||
@State private var semanticResults: [Conversation] = []
|
@State private var 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() {
|
||||||
|
|||||||
@@ -80,17 +80,6 @@ struct CreditsView: View {
|
|||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|
||||||
case .appleOnDevice:
|
|
||||||
Text("Apple Intelligence")
|
|
||||||
.font(.headline)
|
|
||||||
Text("On-device and free — no credits or API key needed.")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Image(systemName: "apple.logo")
|
|
||||||
.font(.system(size: 40))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.top)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import FoundationModels
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -307,29 +306,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple Intelligence
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Apple Intelligence")
|
|
||||||
formSection {
|
|
||||||
row("Status") {
|
|
||||||
appleIntelligenceStatusBadge
|
|
||||||
}
|
|
||||||
rowDivider()
|
|
||||||
row("Model") {
|
|
||||||
Text("On-Device (4K context)")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
rowDivider()
|
|
||||||
row("") {
|
|
||||||
Button("Open Apple Intelligence Settings") {
|
|
||||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.aisettings") {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Features")
|
sectionHeader("Features")
|
||||||
@@ -2673,28 +2649,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
formatter.unitsStyle = .full
|
formatter.unitsStyle = .full
|
||||||
return formatter.localizedString(for: date, relativeTo: .now)
|
return formatter.localizedString(for: date, relativeTo: .now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var appleIntelligenceStatusBadge: some View {
|
|
||||||
let availability = SystemLanguageModel.default.availability
|
|
||||||
switch availability {
|
|
||||||
case .available:
|
|
||||||
Label("Available", systemImage: "checkmark.circle.fill")
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
case .unavailable(.deviceNotEligible):
|
|
||||||
Label("Not supported on this Mac", systemImage: "xmark.circle.fill")
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
case .unavailable(.appleIntelligenceNotEnabled):
|
|
||||||
Label("Not enabled — open Apple Intelligence Settings", systemImage: "exclamationmark.circle.fill")
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
case .unavailable(.modelNotReady):
|
|
||||||
Label("Model downloading…", systemImage: "arrow.down.circle.fill")
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
default:
|
|
||||||
Label("Unavailable", systemImage: "questionmark.circle.fill")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
Reference in New Issue
Block a user