diff --git a/.gitignore b/.gitignore index 449b38f..163d5e2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ ## User settings xcuserdata/ +xcshareddata/ ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) *.xcscmblueprint diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 522ac2d..64ac5cb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -72,17 +72,6 @@ oAI/ ## Building -### Build Scripts - -| Script | Architecture | Output | -|--------|-------------|--------| -| `build.sh` | Apple Silicon (arm64) | Installs directly to `/Applications` | -| `build-dmg.sh` | Apple Silicon (arm64) | `oAI--AppleSilicon.dmg` on Desktop | -| `build-dmg-universal.sh` | Universal (arm64 + x86_64) | `oAI--Universal.dmg` on Desktop | -| `build_nb/sv/da/de/en.sh` | Apple Silicon (arm64) | Build + launch in specific language | - -All scripts: find Developer ID cert, clean-build via `xcodebuild`, re-sign with `codesign --options runtime --timestamp`, verify. Version is read from `MARKETING_VERSION` in `project.pbxproj`. - ### Manual Build Commands ```bash diff --git a/README.md b/README.md index eeb6309..cc9ca76 100644 --- a/README.md +++ b/README.md @@ -332,9 +332,6 @@ This means you are free to use, study, modify, and distribute oAI, but any modif See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html). -## Development - -See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, database schema, and contribution guidelines. ## Author @@ -344,9 +341,6 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, datab - Blog: [https://blog.rune.pm](https://blog.rune.pm) - Gitlab.pm: [@rune](https://gitlab.pm/rune) -## Contributing - -Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instructions and project structure. --- diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 11fd0a9..a2ee833 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -279,11 +279,11 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.3.9; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -323,11 +323,11 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.3.9; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme b/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme deleted file mode 100644 index 1ace073..0000000 --- a/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/oAI/Assets.xcassets/AccentColor.colorset/Contents.json b/oAI/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..12169ab --- /dev/null +++ b/oAI/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCA", + "green" : "0x7A", + "red" : "0x0A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/oAI/Info.plist b/oAI/Info.plist index eee344e..5d5967f 100644 --- a/oAI/Info.plist +++ b/oAI/Info.plist @@ -6,5 +6,13 @@ oAI.help CFBundleHelpBookName oAI Help + CFBundleLocalizations + + en + nb + da + de + sv + diff --git a/oAI/Localizable.xcstrings b/oAI/Localizable.xcstrings index b7fd5c3..46e7901 100644 --- a/oAI/Localizable.xcstrings +++ b/oAI/Localizable.xcstrings @@ -1,6 +1,13 @@ { "sourceLanguage" : "en", "strings" : { + "·" : { + "comment" : "A separator between the message count and the date.", + "isCommentAutoGenerated" : true + }, + "· %@" : { + + }, "(always used)" : { "localizations" : { "da" : { @@ -432,6 +439,12 @@ } } } + }, + "^[%@ message](inflect: true)" : { + + }, + "^[%@ token](inflect: true)" : { + }, "© 2026 [Rune Olsen](https://blog.rune.pm)" : { "comment" : "A copyright notice with the copyright holder's name.", @@ -521,6 +534,7 @@ }, "⌘N New • ⌘M Model • ⌘S Save" : { "comment" : "A hint that appears on macOS when using keyboard shortcuts.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "da" : { @@ -549,6 +563,10 @@ } } }, + "⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly." : { + "comment" : "A warning displayed in the settings view.", + "isCommentAutoGenerated" : true + }, "⚠️ Custom prompt active — only this prompt will be sent to the model." : { "localizations" : { "da" : { @@ -894,6 +912,12 @@ } } } + }, + "🧠" : { + + }, + "1. Open Anytype → Settings → Integrations" : { + }, "1. Open Paperless-NGX → Settings → API Tokens" : { "comment" : "A step in the process of getting a Paperless-NGX API token.", @@ -925,6 +949,10 @@ } } }, + "2. Create a new API key" : { + "comment" : "A step in the process of getting an API key from Anytype.", + "isCommentAutoGenerated" : true + }, "2. Create or copy your token" : { "comment" : "A step in the process of getting a Paperless-NGX API token.", "isCommentAutoGenerated" : true, @@ -1132,6 +1160,9 @@ } } } + }, + "Agent" : { + }, "Agent Skills" : { "localizations" : { @@ -1160,6 +1191,9 @@ } } } + }, + "Agents" : { + }, "Allow Shell Command?" : { "comment" : "A title for a modal that asks the user if they want to allow a shell command.", @@ -1566,6 +1600,9 @@ } } } + }, + "Category" : { + }, "Changing these values affects how the AI generates responses. The defaults work well for most use cases." : { "localizations" : { @@ -1654,6 +1691,9 @@ } } } + }, + "Choose an agent from the list to view details and run history" : { + }, "Clear All" : { "comment" : "A button to clear all email activity logs.", @@ -1918,6 +1958,9 @@ } } } + }, + "Cost" : { + }, "Cost Examples" : { "comment" : "A heading for the cost examples of a model.", @@ -2092,6 +2135,9 @@ } } } + }, + "Disabled" : { + }, "Each command will require your approval before running." : { "localizations" : { @@ -2468,6 +2514,12 @@ } } } + }, + "Filter by Category" : { + + }, + "Generate an API key in your Jarvis settings and paste it above." : { + }, "Google (Gemini embedding)" : { "localizations" : { @@ -2526,6 +2578,12 @@ } } } + }, + "High (~80%)" : { + + }, + "How to get your API key:" : { + }, "How to get your API token:" : { "comment" : "A heading for a section that describes how to get your API token.", @@ -2640,6 +2698,9 @@ } } } + }, + "Input" : { + }, "Large files inflate the system prompt and may hit token limits." : { "comment" : "A warning displayed when a user adds a large file to a skill.", @@ -2726,6 +2787,9 @@ } } } + }, + "Low (~20%)" : { + }, "Lowercase letters, numbers, and hyphens only. No spaces." : { "comment" : "A description of the format of a shortcut's command.", @@ -2842,6 +2906,9 @@ } } } + }, + "Medium (~50%)" : { + }, "messages" : { "localizations" : { @@ -2870,6 +2937,9 @@ } } } + }, + "Minimal (~10%)" : { + }, "Model Context Protocol" : { "localizations" : { @@ -2928,6 +2998,9 @@ } } } + }, + "Model thinks internally but reasoning is not shown in chat" : { + }, "Multi-provider AI chat client" : { "comment" : "A description of oAI.", @@ -3018,6 +3091,9 @@ } } } + }, + "New Chat" : { + }, "No credit data available" : { "comment" : "A message displayed when there is no credit data available.", @@ -3196,6 +3272,9 @@ } } } + }, + "No runs yet" : { + }, "No shortcuts yet" : { "comment" : "A message displayed when a user has no shortcuts.", @@ -3347,6 +3426,10 @@ } } }, + "oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates." : { + "comment" : "A warning that Intel Macs are no longer supported.", + "isCommentAutoGenerated" : true + }, "Ollama (Local)" : { "comment" : "A label displayed above the credits information for the local Ollie.", "isCommentAutoGenerated" : true, @@ -3574,6 +3657,9 @@ } } } + }, + "OpenRouter Balance" : { + }, "OpenRouter Credits" : { "comment" : "A heading for the user's OpenRouter credits.", @@ -3604,6 +3690,12 @@ } } } + }, + "Output" : { + + }, + "Prompt" : { + }, "Read access (always enabled)" : { "localizations" : { @@ -3632,6 +3724,9 @@ } } } + }, + "Reasoning" : { + }, "Remote: %@" : { "localizations" : { @@ -3690,6 +3785,12 @@ } } } + }, + "Run History" : { + + }, + "Run some agents to see usage statistics" : { + }, "Running locally — no credits needed!" : { "comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.", @@ -3721,6 +3822,10 @@ } } }, + "Runs" : { + "comment" : "A column header for the number of runs.", + "isCommentAutoGenerated" : true + }, "Security Recommendation" : { "localizations" : { "da" : { @@ -3866,6 +3971,9 @@ } } } + }, + "Sort" : { + }, "SSH Key" : { "localizations" : { @@ -4180,6 +4288,9 @@ } } } + }, + "Thinking…" : { + }, "This default prompt is always included to ensure accurate, helpful responses." : { "localizations" : { @@ -4296,6 +4407,15 @@ } } } + }, + "Total" : { + + }, + "Total Credits" : { + + }, + "Total Used" : { + }, "Try adjusting your search or filters" : { "comment" : "A description of the error that occurs when no models match the user's search.", @@ -4444,6 +4564,9 @@ } } } + }, + "Usage" : { + }, "Use @filename to attach files to your message" : { "comment" : "A description of how to attach files to a message.", @@ -4565,6 +4688,7 @@ }, "v%@" : { "comment" : "A label showing the current version of oAI.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "da" : { @@ -4744,6 +4868,10 @@ } } } + }, + "β" : { + "comment" : "A beta badge.", + "isCommentAutoGenerated" : true } }, "version" : "1.1" diff --git a/oAI/Models/Conversation.swift b/oAI/Models/Conversation.swift index bd33df7..6a1bb3d 100644 --- a/oAI/Models/Conversation.swift +++ b/oAI/Models/Conversation.swift @@ -33,7 +33,7 @@ struct Conversation: Identifiable, Codable { var updatedAt: Date var primaryModel: String? // Primary model used in this conversation - init( + nonisolated init( id: UUID = UUID(), name: String, messages: [Message] = [], diff --git a/oAI/Models/EmailLog.swift b/oAI/Models/EmailLog.swift index b2af49d..75ade66 100644 --- a/oAI/Models/EmailLog.swift +++ b/oAI/Models/EmailLog.swift @@ -44,7 +44,7 @@ struct EmailLog: Identifiable, Codable, Equatable { let responseTime: TimeInterval? // Time to generate response in seconds let modelId: String? // Model that handled the email - init( + nonisolated init( id: UUID = UUID(), timestamp: Date = Date(), sender: String, diff --git a/oAI/Models/Message.swift b/oAI/Models/Message.swift index 56c2e27..8914cf2 100644 --- a/oAI/Models/Message.swift +++ b/oAI/Models/Message.swift @@ -66,7 +66,7 @@ struct Message: Identifiable, Codable, Equatable { // Reasoning/thinking content (not persisted — in-memory only) var thinkingContent: String? = nil - init( + nonisolated init( id: UUID = UUID(), role: MessageRole, content: String, diff --git a/oAI/Providers/AIProvider.swift b/oAI/Providers/AIProvider.swift index da6d418..10f2d00 100644 --- a/oAI/Providers/AIProvider.swift +++ b/oAI/Providers/AIProvider.swift @@ -130,11 +130,23 @@ struct ChatResponse: Codable { let promptTokens: Int let completionTokens: Int let totalTokens: Int + let cacheCreationInputTokens: Int? + let cacheReadInputTokens: Int? + + init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil) { + self.promptTokens = promptTokens + self.completionTokens = completionTokens + self.totalTokens = totalTokens + self.cacheCreationInputTokens = cacheCreationInputTokens + self.cacheReadInputTokens = cacheReadInputTokens + } enum CodingKeys: String, CodingKey { case promptTokens = "prompt_tokens" case completionTokens = "completion_tokens" case totalTokens = "total_tokens" + case cacheCreationInputTokens = "cache_creation_input_tokens" + case cacheReadInputTokens = "cache_read_input_tokens" } } diff --git a/oAI/Providers/AnthropicProvider.swift b/oAI/Providers/AnthropicProvider.swift index 8e17e8f..d801467 100644 --- a/oAI/Providers/AnthropicProvider.swift +++ b/oAI/Providers/AnthropicProvider.swift @@ -77,6 +77,15 @@ class AnthropicProvider: AIProvider { /// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301") /// still inherit the correct pricing tier. private static let knownModels: [ModelInfo] = [ + // Claude Fable 5 + ModelInfo( + id: "claude-fable-5", + name: "Claude Fable 5", + description: "Anthropic's creative and storytelling model", + contextLength: 200_000, + pricing: .init(prompt: 10.0, completion: 50.0), + capabilities: .init(vision: true, tools: true, online: true) + ), // Claude 4.x series ModelInfo( id: "claude-opus-4-6", @@ -173,6 +182,7 @@ class AnthropicProvider: AIProvider { /// Pricing tiers used for fuzzy fallback matching on unknown model IDs. /// Keyed by model name prefix (longest match wins). private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [ + ("claude-fable", 10.0, 50.0), ("claude-opus", 15.0, 75.0), ("claude-sonnet", 3.0, 15.0), ("claude-haiku", 0.80, 4.0), @@ -356,6 +366,19 @@ class AnthropicProvider: AIProvider { } } + // Mark the last message with a cache breakpoint so the next loop + // iteration (or next turn) can reuse everything up through this one. + if var lastMessage = conversationMessages.popLast() { + if let content = lastMessage["content"] as? String { + lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]] + } else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() { + lastBlock["cache_control"] = ["type": "ephemeral"] + blocks.append(lastBlock) + lastMessage["content"] = blocks + } + conversationMessages.append(lastMessage) + } + var body: [String: Any] = [ "model": model, "messages": conversationMessages, @@ -363,7 +386,9 @@ class AnthropicProvider: AIProvider { "stream": false ] if let systemText = systemText { - body["system"] = systemText + // Array form carries a cache breakpoint; also covers tools, which + // render before system in Anthropic's prefix order. + body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]] } if let temperature = temperature { body["temperature"] = temperature @@ -430,6 +455,8 @@ class AnthropicProvider: AIProvider { var currentId = "" var currentModel = request.model var inputTokens = 0 + var cacheCreationTokens: Int? = nil + var cacheReadTokens: Int? = nil for try await line in bytes.lines { // Anthropic SSE: "event: ..." and "data: {...}" @@ -449,6 +476,11 @@ class AnthropicProvider: AIProvider { currentModel = message["model"] as? String ?? request.model if let usageDict = message["usage"] as? [String: Any] { inputTokens = usageDict["input_tokens"] as? Int ?? 0 + cacheCreationTokens = usageDict["cache_creation_input_tokens"] as? Int + cacheReadTokens = usageDict["cache_read_input_tokens"] as? Int + if cacheCreationTokens != nil || cacheReadTokens != nil { + Log.api.info("Anthropic stream cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)") + } } } @@ -472,7 +504,13 @@ class AnthropicProvider: AIProvider { var usage: ChatResponse.Usage? = nil if let usageDict = event["usage"] as? [String: Any] { let outputTokens = usageDict["output_tokens"] as? Int ?? 0 - usage = ChatResponse.Usage(promptTokens: inputTokens, completionTokens: outputTokens, totalTokens: inputTokens + outputTokens) + usage = ChatResponse.Usage( + promptTokens: inputTokens, + completionTokens: outputTokens, + totalTokens: inputTokens + outputTokens, + cacheCreationInputTokens: cacheCreationTokens, + cacheReadInputTokens: cacheReadTokens + ) } continuation.yield(StreamChunk( id: currentId, @@ -582,6 +620,19 @@ class AnthropicProvider: AIProvider { } } + // Mark the last message with a cache breakpoint so the next turn can + // reuse everything up through this one as a cached prefix. + if var lastMessage = apiMessages.popLast() { + if let content = lastMessage["content"] as? String { + lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]] + } else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() { + lastBlock["cache_control"] = ["type": "ephemeral"] + blocks.append(lastBlock) + lastMessage["content"] = blocks + } + apiMessages.append(lastMessage) + } + var body: [String: Any] = [ "model": request.model, "messages": apiMessages, @@ -590,7 +641,10 @@ class AnthropicProvider: AIProvider { ] if let systemText = systemText { - body["system"] = systemText + // Array form (rather than a plain string) carries a cache breakpoint. + // Per Anthropic's render order (tools -> system -> messages), this + // single breakpoint caches the tool definitions too. + body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]] } if let temperature = request.temperature { body["temperature"] = temperature @@ -665,6 +719,11 @@ class AnthropicProvider: AIProvider { let usageDict = json["usage"] as? [String: Any] let inputTokens = usageDict?["input_tokens"] as? Int ?? 0 let outputTokens = usageDict?["output_tokens"] as? Int ?? 0 + let cacheCreationTokens = usageDict?["cache_creation_input_tokens"] as? Int + let cacheReadTokens = usageDict?["cache_read_input_tokens"] as? Int + if cacheCreationTokens != nil || cacheReadTokens != nil { + Log.api.info("Anthropic cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)") + } return ChatResponse( id: id, @@ -675,7 +734,9 @@ class AnthropicProvider: AIProvider { usage: ChatResponse.Usage( promptTokens: inputTokens, completionTokens: outputTokens, - totalTokens: inputTokens + outputTokens + totalTokens: inputTokens + outputTokens, + cacheCreationInputTokens: cacheCreationTokens, + cacheReadInputTokens: cacheReadTokens ), created: Date(), toolCalls: toolCalls.isEmpty ? nil : toolCalls diff --git a/oAI/Providers/OpenRouterModels.swift b/oAI/Providers/OpenRouterModels.swift index 7701a60..4fbb6b3 100644 --- a/oAI/Providers/OpenRouterModels.swift +++ b/oAI/Providers/OpenRouterModels.swift @@ -48,7 +48,12 @@ struct OpenRouterChatRequest: Codable { let toolChoice: String? let modalities: [String]? let reasoning: ReasoningAPIConfig? - + let cacheControl: CacheControl? + + struct CacheControl: Codable { + let type: String + } + struct APIMessage: Codable { let role: String let content: MessageContent @@ -138,6 +143,7 @@ struct OpenRouterChatRequest: Codable { case toolChoice = "tool_choice" case modalities case reasoning + case cacheControl = "cache_control" } } @@ -225,11 +231,23 @@ struct OpenRouterChatResponse: Codable { let promptTokens: Int let completionTokens: Int let totalTokens: Int - + let promptTokensDetails: PromptTokensDetails? + + struct PromptTokensDetails: Codable { + let cachedTokens: Int? + let cacheWriteTokens: Int? + + enum CodingKeys: String, CodingKey { + case cachedTokens = "cached_tokens" + case cacheWriteTokens = "cache_write_tokens" + } + } + enum CodingKeys: String, CodingKey { case promptTokens = "prompt_tokens" case completionTokens = "completion_tokens" case totalTokens = "total_tokens" + case promptTokensDetails = "prompt_tokens_details" } } } diff --git a/oAI/Providers/OpenRouterProvider.swift b/oAI/Providers/OpenRouterProvider.swift index 18e7fed..bcc874a 100644 --- a/oAI/Providers/OpenRouterProvider.swift +++ b/oAI/Providers/OpenRouterProvider.swift @@ -198,6 +198,11 @@ class OpenRouterProvider: AIProvider { } if let maxTokens = maxTokens { body["max_tokens"] = maxTokens } if let temperature = temperature { body["temperature"] = temperature } + // Anthropic models require an explicit cache_control opt-in on OpenRouter; + // other providers cache automatically. + if model.hasPrefix("anthropic/") { + body["cache_control"] = ["type": "ephemeral"] + } var urlRequest = URLRequest(url: url) urlRequest.httpMethod = "POST" @@ -388,6 +393,12 @@ class OpenRouterProvider: AIProvider { ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil) } + // Anthropic models require an explicit cache_control opt-in on OpenRouter; + // other providers (OpenAI, DeepSeek, Gemini, Grok, etc.) cache automatically. + let cacheControl: OpenRouterChatRequest.CacheControl? = effectiveModel.hasPrefix("anthropic/") + ? .init(type: "ephemeral") + : nil + return OpenRouterChatRequest( model: effectiveModel, messages: apiMessages, @@ -398,7 +409,8 @@ class OpenRouterProvider: AIProvider { tools: request.tools, toolChoice: request.tools != nil ? "auto" : nil, modalities: request.imageGeneration ? ["text", "image"] : nil, - reasoning: reasoningConfig + reasoning: reasoningConfig, + cacheControl: cacheControl ) } @@ -416,6 +428,11 @@ class OpenRouterProvider: AIProvider { let allImages = topLevelImages + blockImages let images: [Data]? = allImages.isEmpty ? nil : allImages + if let details = apiResponse.usage?.promptTokensDetails, + details.cachedTokens != nil || details.cacheWriteTokens != nil { + Log.api.info("OpenRouter cache usage: model=\(apiResponse.model), created=\(details.cacheWriteTokens ?? 0), read=\(details.cachedTokens ?? 0)") + } + return ChatResponse( id: apiResponse.id, model: apiResponse.model, @@ -426,7 +443,9 @@ class OpenRouterProvider: AIProvider { ChatResponse.Usage( promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, - totalTokens: usage.totalTokens + totalTokens: usage.totalTokens, + cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens, + cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens ) }, created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)), @@ -446,6 +465,11 @@ class OpenRouterProvider: AIProvider { let allImages = topLevelImages + blockImages let images: [Data]? = allImages.isEmpty ? nil : allImages + if let details = apiChunk.usage?.promptTokensDetails, + details.cachedTokens != nil || details.cacheWriteTokens != nil { + Log.api.info("OpenRouter stream cache usage: model=\(apiChunk.model), created=\(details.cacheWriteTokens ?? 0), read=\(details.cachedTokens ?? 0)") + } + return StreamChunk( id: apiChunk.id, model: apiChunk.model, @@ -460,7 +484,9 @@ class OpenRouterProvider: AIProvider { ChatResponse.Usage( promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, - totalTokens: usage.totalTokens + totalTokens: usage.totalTokens, + cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens, + cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens ) } ) diff --git a/oAI/Services/ConversationMergeService.swift b/oAI/Services/ConversationMergeService.swift new file mode 100644 index 0000000..ffc3569 --- /dev/null +++ b/oAI/Services/ConversationMergeService.swift @@ -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 . + + +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 + } +} diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index f689610..ab14649 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -134,15 +134,29 @@ final class DatabaseService: Sendable { nonisolated static let shared = DatabaseService() private let dbQueue: DatabaseQueue - private let isoFormatter: ISO8601DateFormatter // Command history limit - keep most recent 5000 entries - private static let maxHistoryEntries = 5000 + private nonisolated static let maxHistoryEntries = 5000 + + // ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable). + private nonisolated static let isoStyle = Date.ISO8601FormatStyle( + dateSeparator: .dash, + dateTimeSeparator: .standard, + timeSeparator: .colon, + timeZoneSeparator: .colon, + includingFractionalSeconds: true, + timeZone: .gmt + ) + + private nonisolated static func isoString(from date: Date) -> String { + isoStyle.format(date) + } + + private nonisolated static func isoDate(from string: String) -> Date? { + (try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601)) + } nonisolated private init() { - isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let fileManager = FileManager.default let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true) @@ -156,7 +170,7 @@ final class DatabaseService: Sendable { try! migrator.migrate(dbQueue) } - private var migrator: DatabaseMigrator { + private nonisolated var migrator: DatabaseMigrator { var migrator = DatabaseMigrator() migrator.registerMigration("v1") { db in @@ -375,7 +389,7 @@ final class DatabaseService: Sendable { nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation { Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))") let now = Date() - let nowString = isoFormatter.string(from: now) + let nowString = Self.isoString(from: now) let convRecord = ConversationRecord( id: id.uuidString, @@ -394,7 +408,7 @@ final class DatabaseService: Sendable { content: msg.content, tokens: msg.tokens, cost: msg.cost, - timestamp: isoFormatter.string(from: msg.timestamp), + timestamp: Self.isoString(from: msg.timestamp), sortOrder: index, modelId: msg.modelId ) @@ -420,7 +434,7 @@ final class DatabaseService: Sendable { /// Update an existing conversation in-place: rename it, replace all its messages. nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws { - let nowString = isoFormatter.string(from: Date()) + let nowString = Self.isoString(from: Date()) let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in guard msg.role != .system else { return nil } @@ -431,7 +445,7 @@ final class DatabaseService: Sendable { content: msg.content, tokens: msg.tokens, cost: msg.cost, - timestamp: isoFormatter.string(from: msg.timestamp), + timestamp: Self.isoString(from: msg.timestamp), sortOrder: index, modelId: msg.modelId ) @@ -466,7 +480,7 @@ final class DatabaseService: Sendable { let messages = messageRecords.compactMap { record -> Message? in guard let msgId = UUID(uuidString: record.id), let role = MessageRole(rawValue: record.role), - let timestamp = self.isoFormatter.date(from: record.timestamp) + let timestamp = Self.isoDate(from: record.timestamp) else { return nil } let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1 @@ -484,8 +498,8 @@ final class DatabaseService: Sendable { } guard let convId = UUID(uuidString: convRecord.id), - let createdAt = self.isoFormatter.date(from: convRecord.createdAt), - let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt) + let createdAt = Self.isoDate(from: convRecord.createdAt), + let updatedAt = Self.isoDate(from: convRecord.updatedAt) else { return nil } let conversation = Conversation( @@ -509,8 +523,8 @@ final class DatabaseService: Sendable { return records.compactMap { record -> Conversation? in guard let id = UUID(uuidString: record.id), - let createdAt = self.isoFormatter.date(from: record.createdAt), - let updatedAt = self.isoFormatter.date(from: record.updatedAt) + let createdAt = Self.isoDate(from: record.createdAt), + let updatedAt = Self.isoDate(from: record.updatedAt) else { return nil } // Fetch message count without loading all messages @@ -524,7 +538,7 @@ final class DatabaseService: Sendable { .order(Column("sortOrder").desc) .fetchOne(db) - let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt + let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt // Derive primary model: prefer the stored field, fall back to last message's modelId let primaryModel = record.primaryModel ?? lastMsg?.modelId @@ -574,7 +588,7 @@ final class DatabaseService: Sendable { convRecord.name = name } - convRecord.updatedAt = self.isoFormatter.string(from: Date()) + convRecord.updatedAt = Self.isoString(from: Date()) try convRecord.update(db) if let messages = messages { @@ -589,7 +603,7 @@ final class DatabaseService: Sendable { content: msg.content, tokens: msg.tokens, cost: msg.cost, - timestamp: self.isoFormatter.string(from: msg.timestamp), + timestamp: Self.isoString(from: msg.timestamp), sortOrder: index ) } @@ -610,7 +624,7 @@ final class DatabaseService: Sendable { let record = HistoryRecord( id: UUID().uuidString, input: input, - timestamp: isoFormatter.string(from: now) + timestamp: Self.isoString(from: now) ) try? dbQueue.write { db in @@ -643,7 +657,7 @@ final class DatabaseService: Sendable { .fetchAll(db) return records.compactMap { record in - guard let date = isoFormatter.date(from: record.timestamp) else { + guard let date = Self.isoDate(from: record.timestamp) else { return nil } return (input: record.input, timestamp: date) @@ -659,7 +673,7 @@ final class DatabaseService: Sendable { .fetchAll(db) return records.compactMap { record in - guard let date = isoFormatter.date(from: record.timestamp) else { + guard let date = Self.isoDate(from: record.timestamp) else { return nil } return (input: record.input, timestamp: date) @@ -672,7 +686,7 @@ final class DatabaseService: Sendable { nonisolated func saveEmailLog(_ log: EmailLog) { let record = EmailLogRecord( id: log.id.uuidString, - timestamp: isoFormatter.string(from: log.timestamp), + timestamp: Self.isoString(from: log.timestamp), sender: log.sender, subject: log.subject, emailContent: log.emailContent, @@ -698,7 +712,7 @@ final class DatabaseService: Sendable { .fetchAll(db) return records.compactMap { record in - guard let timestamp = isoFormatter.date(from: record.timestamp), + guard let timestamp = Self.isoDate(from: record.timestamp), let status = EmailLogStatus(rawValue: record.status), let id = UUID(uuidString: record.id) else { return nil @@ -805,7 +819,7 @@ final class DatabaseService: Sendable { // MARK: - Embedding Operations nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws { - let now = isoFormatter.string(from: Date()) + let now = Self.isoString(from: Date()) let record = MessageEmbeddingRecord( message_id: messageId.uuidString, embedding: embedding, @@ -825,7 +839,7 @@ final class DatabaseService: Sendable { } nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws { - let now = isoFormatter.string(from: Date()) + let now = Self.isoString(from: Date()) let record = ConversationEmbeddingRecord( conversation_id: conversationId.uuidString, embedding: embedding, @@ -881,7 +895,7 @@ final class DatabaseService: Sendable { return Array(results.prefix(limit)) } - private func deserializeEmbedding(_ data: Data) -> [Float] { + private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] { var embedding: [Float] = [] embedding.reserveCapacity(data.count / 4) @@ -905,7 +919,7 @@ final class DatabaseService: Sendable { model: String?, tokenCount: Int? ) throws { - let now = isoFormatter.string(from: Date()) + let now = Self.isoString(from: Date()) let record = ConversationSummaryRecord( id: UUID().uuidString, conversation_id: conversationId.uuidString, diff --git a/oAI/Services/EmbeddingService.swift b/oAI/Services/EmbeddingService.swift index 391f1d5..29b6f17 100644 --- a/oAI/Services/EmbeddingService.swift +++ b/oAI/Services/EmbeddingService.swift @@ -71,7 +71,7 @@ enum EmbeddingProvider { // MARK: - Embedding Service final class EmbeddingService { - static let shared = EmbeddingService() + nonisolated static let shared = EmbeddingService() private let settings = SettingsService.shared @@ -281,7 +281,7 @@ final class EmbeddingService { // MARK: - Similarity Calculation /// Calculate cosine similarity between two embeddings - func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { + nonisolated func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { guard a.count == b.count else { Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)") return 0.0 diff --git a/oAI/Services/EncryptionService.swift b/oAI/Services/EncryptionService.swift index b23d92c..70711a4 100644 --- a/oAI/Services/EncryptionService.swift +++ b/oAI/Services/EncryptionService.swift @@ -29,19 +29,18 @@ import CryptoKit import IOKit class EncryptionService { - static let shared = EncryptionService() + nonisolated static let shared = EncryptionService() - private let salt = "oAI-secure-storage-v1" // App-specific salt - private lazy var encryptionKey: SymmetricKey = { - deriveEncryptionKey() - }() + private let encryptionKey: SymmetricKey - private init() {} + private init() { + self.encryptionKey = Self.deriveEncryptionKey() + } // MARK: - Public Interface /// Encrypt a string value - func encrypt(_ value: String) throws -> String { + nonisolated func encrypt(_ value: String) throws -> String { guard let data = value.data(using: .utf8) else { throw EncryptionError.invalidInput } @@ -55,7 +54,7 @@ class EncryptionService { } /// Decrypt a string value - func decrypt(_ encryptedValue: String) throws -> String { + nonisolated func decrypt(_ encryptedValue: String) throws -> String { guard let data = Data(base64Encoded: encryptedValue) else { throw EncryptionError.invalidInput } @@ -73,19 +72,17 @@ class EncryptionService { // MARK: - Key Derivation /// Derive encryption key from machine-specific data - private func deriveEncryptionKey() -> SymmetricKey { - // Combine machine UUID + bundle ID + salt for key material + private static func deriveEncryptionKey() -> SymmetricKey { let machineUUID = getMachineUUID() let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI" + let salt = "oAI-secure-storage-v1" let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)" - - // Hash to create consistent 256-bit key let hash = SHA256.hash(data: Data(keyMaterial.utf8)) return SymmetricKey(data: hash) } /// Get machine-specific UUID (IOPlatformUUID) - private func getMachineUUID() -> String { + private static func getMachineUUID() -> String { // Get IOPlatformUUID from IOKit let platformExpert = IOServiceGetMatchingService( kIOMainPortDefault, diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index 068c4c4..2605f35 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -212,7 +212,7 @@ class GitSyncService { // Check if conversation already exists (by ID) if let existingId = UUID(uuidString: export.id) { - if let existing = try? db.loadConversation(id: existingId) { + if (try? db.loadConversation(id: existingId)) != nil { // Already exists - skip log.debug("Skipping existing conversation: \(export.name)") skipped += 1 diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 67a1403..5191f70 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -300,6 +300,24 @@ class SettingsService { } } + /// Input bar height in points — default 80 + var inputBarHeight: Double { + get { cache["inputBarHeight"].flatMap(Double.init) ?? 80.0 } + set { + cache["inputBarHeight"] = String(newValue) + DatabaseService.shared.setSetting(key: "inputBarHeight", value: String(newValue)) + } + } + + /// Whether the sidebar is visible — default true + var sidebarVisible: Bool { + get { cache["sidebarVisible"] != "false" } + set { + cache["sidebarVisible"] = String(newValue) + DatabaseService.shared.setSetting(key: "sidebarVisible", value: String(newValue)) + } + } + // MARK: - MCP Permissions var mcpCanWriteFiles: Bool { diff --git a/oAI/Services/UpdateCheckService.swift b/oAI/Services/UpdateCheckService.swift index d2d3c44..98f087f 100644 --- a/oAI/Services/UpdateCheckService.swift +++ b/oAI/Services/UpdateCheckService.swift @@ -35,6 +35,11 @@ final class UpdateCheckService { var updateAvailable: Bool = false var latestVersion: String? = nil + var downloadURL: URL? = nil + + // Manual check state — drives the update alert in ContentView + var isCheckingManually: Bool = false + var manualCheckMessage: String? = nil private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest" private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")! @@ -48,6 +53,24 @@ final class UpdateCheckService { } } + /// Manual check triggered from the Help menu. Non-blocking — result surfaces via manualCheckMessage. + func checkForUpdatesManually() { + guard !isCheckingManually else { return } + isCheckingManually = true + Task.detached(priority: .background) { + await self.performCheck() + let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + await MainActor.run { + if self.updateAvailable, let v = self.latestVersion { + self.manualCheckMessage = String(localized: "Version \(v) is available.") + } else { + self.manualCheckMessage = String(localized: "You're up to date (v\(current)).") + } + self.isCheckingManually = false + } + } + } + private func performCheck() async { guard let url = URL(string: apiURL) else { return } @@ -69,9 +92,16 @@ final class UpdateCheckService { let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + // Extract direct DMG download URL from release assets + let dmgURL: URL? = (release["assets"] as? [[String: Any]])? + .first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") } + .flatMap { $0["browser_download_url"] as? String } + .flatMap { URL(string: $0) } + if isNewer(latestVer, than: currentVer) { await MainActor.run { self.latestVersion = latestVer + self.downloadURL = dmgURL self.updateAvailable = true } } diff --git a/oAI/Utilities/Logging.swift b/oAI/Utilities/Logging.swift index 7ca90e1..93dac72 100644 --- a/oAI/Utilities/Logging.swift +++ b/oAI/Utilities/Logging.swift @@ -52,7 +52,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable { } } - static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + nonisolated static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rawValue < rhs.rawValue } } @@ -60,7 +60,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable { // MARK: - File Logger final class FileLogger: @unchecked Sendable { - static let shared = FileLogger() + nonisolated static let shared = FileLogger() private let fileHandle: FileHandle? private let queue = DispatchQueue(label: "com.oai.filelogger") @@ -70,8 +70,8 @@ final class FileLogger: @unchecked Sendable { return f }() - /// Current minimum log level (read from UserDefaults for thread safety) - var minimumLevel: LogLevel { + /// Current minimum log level (backed by UserDefaults — thread-safe). + nonisolated var minimumLevel: LogLevel { get { let raw = UserDefaults.standard.integer(forKey: "logLevel") return LogLevel(rawValue: raw) ?? .info @@ -95,7 +95,7 @@ final class FileLogger: @unchecked Sendable { fileHandle?.seekToEndOfFile() } - func write(_ level: LogLevel, category: String, message: String) { + nonisolated func write(_ level: LogLevel, category: String, message: String) { guard level >= minimumLevel else { return } queue.async { [weak self] in guard let self, let fh = self.fileHandle else { return } @@ -114,41 +114,43 @@ final class FileLogger: @unchecked Sendable { // MARK: - App Logger (wraps os.Logger + file) -struct AppLogger { - let osLogger: Logger +// os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are +// nonisolated — FileLogger runs on its own serial queue, os.Logger dispatches to main actor. +struct AppLogger: Sendable { + let subsystem: String let category: String - func debug(_ message: String) { + nonisolated func debug(_ message: String) { FileLogger.shared.write(.debug, category: category, message: message) - osLogger.debug("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).debug("\(message, privacy: .public)") } } - func info(_ message: String) { + nonisolated func info(_ message: String) { FileLogger.shared.write(.info, category: category, message: message) - osLogger.info("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).info("\(message, privacy: .public)") } } - func warning(_ message: String) { + nonisolated func warning(_ message: String) { FileLogger.shared.write(.warning, category: category, message: message) - osLogger.warning("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).warning("\(message, privacy: .public)") } } - func error(_ message: String) { + nonisolated func error(_ message: String) { FileLogger.shared.write(.error, category: category, message: message) - osLogger.error("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") } } } // MARK: - Log Namespace enum Log { - private static let subsystem = "com.oai.oAI" + private nonisolated static let subsystem = "com.oai.oAI" - static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api") - static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database") - static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp") - static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings") - static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search") - static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui") - static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general") + nonisolated static let api = AppLogger(subsystem: subsystem, category: "api") + nonisolated static let db = AppLogger(subsystem: subsystem, category: "database") + nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp") + nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings") + nonisolated static let search = AppLogger(subsystem: subsystem, category: "search") + nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui") + nonisolated static let general = AppLogger(subsystem: subsystem, category: "general") } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index ab5a8e9..e217b0b 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -934,10 +934,7 @@ Don't narrate future actions ("Let me...") - just use the tools. messages[index].tokens = usage.completionTokens if let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing - ? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + - (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) - : nil + let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) } @@ -1001,10 +998,7 @@ Don't narrate future actions ("Let me...") - just use the tools. messages[index].tokens = usage.completionTokens if let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing - ? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + - (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) - : nil + let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) } @@ -1313,7 +1307,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Append the complete system prompt (default + custom) systemContent += "\n\n---\n\n" + effectiveSystemPrompt - var messagesToSend: [Message] = memoryEnabled + let messagesToSend: [Message] = memoryEnabled ? messages.filter { $0.role != .system } : [messages.last(where: { $0.role == .user })].compactMap { $0 } @@ -1529,10 +1523,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Calculate cost if let usage = totalUsage, let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing - ? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + - (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) - : nil + let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) { messages[index].cost = cost } @@ -1659,7 +1650,7 @@ Don't narrate future actions ("Let me...") - just use the tools. if isOverloaded && attempt < maxAttempts && !Task.isCancelled { let delay = Double(1 << attempt) // 2s, 4s, 8s Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...") - await MainActor.run { + _ = await MainActor.run { showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))") } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) @@ -2180,6 +2171,18 @@ Don't narrate future actions ("Let me...") - just use the tools. } } + /// Cost for one response's usage, accounting for Anthropic-style prompt-cache + /// pricing when present: cache writes cost 1.25x the base input rate, cache + /// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder — + /// it does not need cache tokens subtracted from it. + private func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double { + let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000 + let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000 + let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000 + let outputCost = Double(usage.completionTokens) * pricing.completion / 1_000_000 + return inputCost + cacheReadCost + cacheWriteCost + outputCost + } + /// Summarize a chunk of messages into a concise summary private func summarizeMessageChunk(_ messages: [Message]) async -> String? { guard let provider = providerRegistry.getProvider(for: currentProvider), diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift index 52180c1..48b01b7 100644 --- a/oAI/Views/Main/ChatView.swift +++ b/oAI/Views/Main/ChatView.swift @@ -37,12 +37,11 @@ struct ChatView: View { HeaderView( provider: viewModel.currentProvider, model: viewModel.selectedModel, - stats: viewModel.sessionStats, - onlineMode: viewModel.onlineMode, - mcpEnabled: viewModel.mcpEnabled, - mcpStatus: viewModel.mcpStatus, onModelSelect: onModelSelect, - onProviderChange: onProviderChange + onProviderChange: onProviderChange, + conversationName: viewModel.currentConversationName, + hasUnsavedChanges: viewModel.hasUnsavedChanges, + onQuickSave: viewModel.quickSave ) // Messages @@ -85,10 +84,13 @@ struct ChatView: View { InputBar( text: $viewModel.inputText, isGenerating: viewModel.isGenerating, - mcpStatus: viewModel.mcpStatus, onlineMode: viewModel.onlineMode, onSend: viewModel.sendMessage, - onCancel: viewModel.cancelGeneration + onCancel: viewModel.cancelGeneration, + onToggleOnline: { + viewModel.onlineMode.toggle() + SettingsService.shared.onlineMode = viewModel.onlineMode + } ) // Footer @@ -96,7 +98,9 @@ struct ChatView: View { stats: viewModel.sessionStats, conversationName: viewModel.currentConversationName, hasUnsavedChanges: viewModel.hasUnsavedChanges, - onQuickSave: viewModel.quickSave + onQuickSave: viewModel.quickSave, + onlineMode: viewModel.onlineMode, + mcpEnabled: viewModel.mcpEnabled ) } .background(Color.oaiBackground) diff --git a/oAI/Views/Main/ContentView.swift b/oAI/Views/Main/ContentView.swift index 28dba53..0876801 100644 --- a/oAI/Views/Main/ContentView.swift +++ b/oAI/Views/Main/ContentView.swift @@ -2,7 +2,7 @@ // ContentView.swift // oAI // -// Root navigation container +// Root navigation container — NavigationSplitView with collapsible sidebar // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen @@ -24,30 +24,34 @@ import SwiftUI +#if os(macOS) +import Darwin // uname, sysctlbyname +#endif struct ContentView: View { @Environment(ChatViewModel.self) var chatViewModel + private var updateService = UpdateCheckService.shared + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var showIntelWarning = false var body: some View { @Bindable var vm = chatViewModel - NavigationStack { + NavigationSplitView(columnVisibility: $columnVisibility) { + SidebarView() + .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340) + } detail: { ChatView( onModelSelect: { chatViewModel.showModelSelector = true }, onProviderChange: { newProvider in chatViewModel.changeProvider(newProvider) } ) - .navigationTitle("") - .toolbar { - #if os(macOS) - macOSToolbar - #endif - } } .frame(minWidth: 860, minHeight: 560) #if os(macOS) .onAppear { NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed } + checkIntelWarning() } .onKeyPress(.return, phases: .down) { press in if press.modifiers.contains(.command) { @@ -65,7 +69,6 @@ struct ContentView: View { let oldModel = chatViewModel.selectedModel chatViewModel.selectModel(model) chatViewModel.showModelSelector = false - // Trigger auto-save on model switch Task { await chatViewModel.onModelSwitch(from: oldModel, to: model) } @@ -113,125 +116,56 @@ struct ContentView: View { chatViewModel.inputText = input }) } + .alert("Intel Mac Support Ending", isPresented: $showIntelWarning) { + Button("Got It") { + UserDefaults.standard.set(true, forKey: "hasShownIntelWarning") + } + } message: { + Text("oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.") + } + .alert("Software Update", isPresented: Binding( + get: { updateService.manualCheckMessage != nil }, + set: { if !$0 { updateService.manualCheckMessage = nil } } + )) { + if updateService.updateAvailable { + if let url = updateService.downloadURL { + Button("Download v\(updateService.latestVersion ?? "")") { + NSWorkspace.shared.open(url) + } + } + Button("Release Page") { updateService.openReleasesPage() } + Button("Later", role: .cancel) { } + } else { + Button("OK", role: .cancel) { } + } + } message: { + Text(updateService.manualCheckMessage ?? "") + } } #if os(macOS) - @ToolbarContentBuilder - private var macOSToolbar: some ToolbarContent { - let settings = SettingsService.shared - let showLabels = settings.showToolbarLabels - let iconSize = settings.toolbarIconSize + private func checkIntelWarning() { + guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return } + guard isIntelNative || isRosetta else { return } + showIntelWarning = true + } - ToolbarItemGroup(placement: .automatic) { - // New conversation - Button(action: { chatViewModel.newConversation() }) { - ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("n", modifiers: .command) - .help("New conversation") - - Button(action: { chatViewModel.showConversations = true }) { - ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("l", modifiers: .command) - .help("Saved conversations (Cmd+L)") - - Button(action: { chatViewModel.showHistory = true }) { - ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("h", modifiers: .command) - .help("Command history (Cmd+H)") - - Spacer() - - Button(action: { chatViewModel.showModelSelector = true }) { - ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("m", modifiers: .command) - .help("Select AI model (Cmd+M)") - - Button(action: { - if let model = chatViewModel.selectedModel { - chatViewModel.modelInfoTarget = model - } - }) { - ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("i", modifiers: .command) - .help("Model info (Cmd+I)") - .disabled(chatViewModel.selectedModel == nil) - - Button(action: { chatViewModel.showStats = true }) { - ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, iconSize: iconSize) - } - .help("Session statistics") - - Button(action: { chatViewModel.showCredits = true }) { - ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, iconSize: iconSize) - } - .help("Check API credits") - - Spacer() - - Button(action: { chatViewModel.showSettings = true }) { - ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut(",", modifiers: .command) - .help("Settings (Cmd+,)") - - Button(action: { chatViewModel.showHelp = true }) { - ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("/", modifiers: .command) - .help("Help & commands (Cmd+/)") + private var isIntelNative: Bool { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { + String(cString: $0.bindMemory(to: CChar.self).baseAddress!) } + return machine.contains("x86_64") + } + + private var isRosetta: Bool { + var ret: Int32 = 0 + var size = MemoryLayout.size + sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0) + return ret == 1 } #endif - -} - -// Helper view for toolbar labels -struct ToolbarLabel: View { - let title: LocalizedStringKey - let systemImage: String - let showLabels: Bool - let iconSize: Double - - // imageScale for the original range (≤32); explicit font size for the new extra-large range (>32) - private var scale: Image.Scale { - switch iconSize { - case ...18: return .small - case 19...24: return .medium - default: return .large - } - } - - var body: some View { - if iconSize > 32 { - // Extra-large: explicit font size above the system .large ceiling - // Offset by 16 so slider 34→18pt, 36→20pt, 38→22pt, 40→24pt - if showLabels { - Label(title, systemImage: systemImage) - .labelStyle(.titleAndIcon) - .font(.system(size: iconSize - 16)) - } else { - Label(title, systemImage: systemImage) - .labelStyle(.iconOnly) - .font(.system(size: iconSize - 16)) - } - } else { - // Original behaviour — imageScale keeps existing look intact - if showLabels { - Label(title, systemImage: systemImage) - .labelStyle(.titleAndIcon) - .imageScale(scale) - } else { - Label(title, systemImage: systemImage) - .labelStyle(.iconOnly) - .imageScale(scale) - } - } - } } #Preview { diff --git a/oAI/Views/Main/FooterView.swift b/oAI/Views/Main/FooterView.swift index 090f200..85085d7 100644 --- a/oAI/Views/Main/FooterView.swift +++ b/oAI/Views/Main/FooterView.swift @@ -30,15 +30,22 @@ struct FooterView: View { let conversationName: String? let hasUnsavedChanges: Bool let onQuickSave: (() -> Void)? + let onlineMode: Bool + let mcpEnabled: Bool + private let settings = SettingsService.shared init(stats: SessionStats, conversationName: String? = nil, hasUnsavedChanges: Bool = false, - onQuickSave: (() -> Void)? = nil) { + onQuickSave: (() -> Void)? = nil, + onlineMode: Bool = false, + mcpEnabled: Bool = false) { self.stats = stats self.conversationName = conversationName self.hasUnsavedChanges = hasUnsavedChanges self.onQuickSave = onQuickSave + self.onlineMode = onlineMode + self.mcpEnabled = mcpEnabled } var body: some View { @@ -71,26 +78,26 @@ struct FooterView: View { Spacer() - // Save indicator (only when chat has messages) - if stats.messageCount > 0 { - SaveIndicator( - conversationName: conversationName, - hasUnsavedChanges: hasUnsavedChanges, - onSave: onQuickSave - ) + // Status pills — Online, MCP, Sync + #if os(macOS) + HStack(spacing: 6) { + if onlineMode { + StatusPill(icon: "globe", label: "Online", color: .green) + } + if mcpEnabled { + StatusPill(icon: "folder", label: "MCP", color: .blue) + } + if settings.syncEnabled && settings.syncAutoSave { + SyncStatusPill() + } } + #endif - // Update available badge + // Update available badge (shows only when an update exists — no version number) #if os(macOS) UpdateBadge() #endif - // Shortcuts hint - #if os(macOS) - Text("⌘N New • ⌘M Model • ⌘S Save") - .font(.caption2) - .foregroundColor(.oaiSecondary) - #endif } .padding(.horizontal, 16) .padding(.vertical, 8) @@ -242,7 +249,6 @@ struct SyncStatusFooter: View { struct UpdateBadge: View { private let updater = UpdateCheckService.shared - private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" var body: some View { if updater.updateAvailable { @@ -258,10 +264,6 @@ struct UpdateBadge: View { } .buttonStyle(.plain) .help("A new version is available — click to open the releases page") - } else { - Text("v\(currentVersion)") - .font(.caption2) - .foregroundColor(.oaiSecondary) } } } diff --git a/oAI/Views/Main/HeaderView.swift b/oAI/Views/Main/HeaderView.swift index 741be09..b2b8087 100644 --- a/oAI/Views/Main/HeaderView.swift +++ b/oAI/Views/Main/HeaderView.swift @@ -2,7 +2,8 @@ // HeaderView.swift // oAI // -// Header bar with provider, model, and stats +// Slim header — provider, model name, star only. +// Status pills and stats live in SidebarView and FooterView respectively. // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen @@ -28,150 +29,29 @@ import SwiftUI struct HeaderView: View { let provider: Settings.Provider let model: ModelInfo? - let stats: SessionStats - let onlineMode: Bool - let mcpEnabled: Bool - let mcpStatus: String? let onModelSelect: () -> Void let onProviderChange: (Settings.Provider) -> Void + var conversationName: String? = nil + var hasUnsavedChanges: Bool = false + var onQuickSave: (() -> Void)? = nil private let settings = SettingsService.shared private let registry = ProviderRegistry.shared - private let gitSync = GitSyncService.shared var body: some View { - HStack(spacing: 20) { - // Provider picker dropdown — only shows configured providers - Menu { - ForEach(registry.configuredProviders, id: \.self) { p in - Button { - onProviderChange(p) - } label: { - HStack { - Image(systemName: p.iconName) - Text(p.displayName) - if p == provider { - Image(systemName: "checkmark") - } - } - } - } - } label: { - HStack(spacing: 4) { - Image(systemName: provider.iconName) - .font(.system(size: settings.guiTextSize - 2)) - Text(provider.displayName) - .font(.system(size: settings.guiTextSize - 2, weight: .medium)) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 8)) - .opacity(0.7) - } - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.providerColor(provider)) - .cornerRadius(4) - } - .menuStyle(.borderlessButton) - .fixedSize() - .help("Switch provider") - - // Model info (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") - - if let model = model { - let isFav = settings.favoriteModelIds.contains(model.id) - Button(action: { settings.toggleFavoriteModel(model.id) }) { - Image(systemName: isFav ? "star.fill" : "star") - .font(.system(size: settings.guiTextSize - 3)) - .foregroundColor(isFav ? .yellow : .oaiSecondary) - } - .buttonStyle(.plain) - .help(isFav ? "Remove from favorites" : "Add to favorites") + ZStack { + // Left: provider + model + star + HStack(spacing: 12) { + providerMenu + modelButton + starButton + Spacer() } - Spacer() - - // Status indicators - HStack(spacing: 8) { - if model?.capabilities.imageGeneration == true { - StatusPill(icon: "paintbrush", label: "Image", color: .purple) - } - if onlineMode { - StatusPill(icon: "globe", label: "Online", color: .green) - } - if mcpEnabled { - StatusPill(icon: "folder", label: "MCP", color: .blue) - } - if settings.syncEnabled && settings.syncAutoSave { - SyncStatusPill() - } - } - - // Divider between status and stats - if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) { - Divider() - .frame(height: 16) - .opacity(0.5) - } - - // Quick stats - HStack(spacing: 16) { - StatItem(icon: "message", value: "\(stats.messageCount)") - StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay) - StatItem(icon: "dollarsign", value: stats.totalCostDisplay) - } - .font(.caption) + // Center: conversation title (macOS document-title style) + conversationTitle } .padding(.horizontal, 16) - .padding(.vertical, 10) + .padding(.vertical, 8) .background(.ultraThinMaterial) .overlay( Rectangle() @@ -180,25 +60,123 @@ struct HeaderView: View { alignment: .bottom ) } -} -struct StatItem: View { - let icon: String - let value: String - private let settings = SettingsService.shared + // MARK: - Conversation title (center) - 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) + @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) + struct StatusPill: View { let icon: String let label: LocalizedStringKey @@ -284,15 +262,6 @@ struct SyncStatusPill: View { HeaderView( provider: .openrouter, model: ModelInfo.mockModels.first, - stats: SessionStats( - totalInputTokens: 125, - totalOutputTokens: 434, - totalCost: 0.00111, - messageCount: 4 - ), - onlineMode: true, - mcpEnabled: true, - mcpStatus: "MCP", onModelSelect: {}, onProviderChange: { _ in } ) diff --git a/oAI/Views/Main/InputBar.swift b/oAI/Views/Main/InputBar.swift index 7bc7181..1c7fe42 100644 --- a/oAI/Views/Main/InputBar.swift +++ b/oAI/Views/Main/InputBar.swift @@ -2,7 +2,7 @@ // InputBar.swift // oAI // -// Message input bar with status indicators +// Message input bar with resizable height and online toggle // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen @@ -24,19 +24,30 @@ import SwiftUI +#if os(macOS) +import AppKit +#endif struct InputBar: View { @Binding var text: String let isGenerating: Bool - let mcpStatus: String? let onlineMode: Bool let onSend: () -> Void let onCancel: () -> Void + let onToggleOnline: () -> Void private let settings = SettingsService.shared + + // Resizable input height — persisted to settings + @State private var inputHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight) + @State private var dragStartHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight) + @State private var showCommandDropdown = false @State private var selectedSuggestionIndex: Int = 0 - @FocusState private var isInputFocused: Bool + @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 private static let immediateCommands: Set = [ @@ -56,121 +67,108 @@ struct InputBar: View { CommandSuggestionsView( searchText: text, selectedIndex: selectedSuggestionIndex, - onSelect: { command in - selectCommand(command) - } + onSelect: selectCommand ) .frame(width: 400) .frame(maxHeight: 200) .transition(.move(edge: .bottom).combined(with: .opacity)) Spacer() } - .padding(.leading, 96) // Align with input box (status badges + spacing) + .padding(.leading, 16) } - // Input area - HStack(alignment: .bottom, spacing: 12) { - // Status indicators - HStack(spacing: 6) { - if let mcp = mcpStatus { - StatusBadge(text: mcp, color: .blue) - } - if onlineMode { - StatusBadge(text: "🌐", color: .green) - } - } - .frame(width: 80, alignment: .leading) + // Drag-to-resize handle + dragHandle - // Text input + // Input row + HStack(alignment: .bottom, spacing: 12) { + // Text input with globe toggle in bottom-left corner ZStack(alignment: .topLeading) { + // Placeholder if text.isEmpty { Text("Type a message or / for commands...") .font(.system(size: settings.inputTextSize)) .foregroundColor(.oaiSecondary) .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.top, 10) + .allowsHitTesting(false) } - TextEditor(text: $text) - .font(.system(size: settings.inputTextSize)) - .foregroundColor(.oaiPrimary) - .scrollContentBackground(.hidden) - .frame(minHeight: 44, maxHeight: 120) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .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 + // Editor — fills the fixed-height box, bottom area reserved for globe + NativeTextEditor( + text: $text, + font: .systemFont(ofSize: settings.inputTextSize), + textColor: NSColor(Color.oaiPrimary), + isFocused: isInputFocused, + onReturn: { if showCommandDropdown { let suggestions = CommandSuggestionsView.filteredCommands(for: text) if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count { selectCommand(suggestions[selectedSuggestionIndex].command) - return .handled + return true } } - // Return (plain or with Cmd): send message - if !text.isEmpty { - onSend() - return .handled + if !text.isEmpty { onSend(); return true } + return true + }, + onEscape: { + if showCommandDropdown { showCommandDropdown = false; return true } + if isGenerating { onCancel(); return true } + return false + }, + onUpArrow: { + if showCommandDropdown && selectedSuggestionIndex > 0 { + selectedSuggestionIndex -= 1; return true } - // Empty text: do nothing - return .handled + return false + }, + onDownArrow: { + if showCommandDropdown { + let count = CommandSuggestionsView.filteredCommands(for: text).count + if selectedSuggestionIndex < count - 1 { + selectedSuggestionIndex += 1; return true + } + } + return false + }, + onFocusChange: { focused in isInputFocused = focused } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: text) { + showCommandDropdown = text.hasPrefix("/") + selectedSuggestionIndex = 0 + } + .padding(.bottom, 30) + + // Online / offline toggle — bottom-left of the text box + VStack { + 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() } - #endif + } } + .frame(height: inputHeight) .background(Color.oaiSurface) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1) ) - - // Action buttons + + // Send / stop + attach buttons VStack(spacing: 8) { #if os(macOS) - // File attach button Button(action: pickFile) { Image(systemName: "paperclip") .font(.title2) @@ -209,21 +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) { showCommandDropdown = false if Self.immediateCommands.contains(command) { - // Execute immediately text = command onSend() } else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) { - if shortcut.needsInput { - text = command + " " - } else { - text = command - onSend() - } + text = shortcut.needsInput ? command + " " : command + if !shortcut.needsInput { onSend() } } else { - // Put in input for user to complete text = command + " " } } @@ -235,36 +259,14 @@ struct InputBar: View { panel.canChooseDirectories = false panel.canChooseFiles = true panel.message = "Select files to attach" - guard panel.runModal() == .OK else { return } - - let paths = panel.urls.map { $0.path } - // Use @ format (angle brackets) to safely handle paths with spaces - let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ") - - if text.isEmpty { - text = attachmentText + " " - } else { - text += " " + attachmentText - } + let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ") + text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText } #endif } -struct StatusBadge: View { - let text: String - let color: Color - - var body: some View { - Text(text) - .font(.caption) - .foregroundColor(color) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(color.opacity(0.15)) - .cornerRadius(4) - } -} +// MARK: - Command suggestions struct CommandSuggestionsView: View { let searchText: String @@ -304,10 +306,9 @@ struct CommandSuggestionsView: View { ] static func allCommands() -> [(command: String, description: LocalizedStringKey)] { - let shortcuts = SettingsService.shared.userShortcuts.map { s in + SettingsService.shared.userShortcuts.map { s in (s.command, LocalizedStringKey("⚡ \(s.description)")) - } - return builtInCommands + shortcuts + } + builtInCommands } static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] { @@ -344,26 +345,20 @@ struct CommandSuggestionsView: View { .id(suggestion.command) if index < suggestions.count - 1 { - Divider() - .background(Color.oaiBorder) + Divider().background(Color.oaiBorder) } } } } .onChange(of: selectedIndex) { if selectedIndex < suggestions.count { - withAnimation { - proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) - } + withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) } } } } .background(Color.oaiSurface) .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.oaiBorder, lineWidth: 1) - ) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1)) } } @@ -373,10 +368,10 @@ struct CommandSuggestionsView: View { InputBar( text: .constant(""), isGenerating: false, - mcpStatus: "📁 Files", onlineMode: true, onSend: {}, - onCancel: {} + onCancel: {}, + onToggleOnline: {} ) } .background(Color.oaiBackground) diff --git a/oAI/Views/Main/NativeTextEditor.swift b/oAI/Views/Main/NativeTextEditor.swift new file mode 100644 index 0000000..9902925 --- /dev/null +++ b/oAI/Views/Main/NativeTextEditor.swift @@ -0,0 +1,171 @@ +// +// NativeTextEditor.swift +// oAI +// +// NSViewRepresentable text editor with correct Enter-key semantics: +// plain Enter → send, Shift+Enter or Cmd+Enter → newline. +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Rune Olsen + +import SwiftUI +import AppKit + +struct NativeTextEditor: NSViewRepresentable { + @Binding var text: String + var font: NSFont + var textColor: NSColor + var isFocused: Bool + + /// Plain Enter (no modifiers). Return true if the event was consumed. + var onReturn: () -> Bool + /// Escape key. Return true if consumed. + var onEscape: () -> Bool + /// Up arrow. Return true if consumed. + var onUpArrow: () -> Bool + /// Down arrow. Return true if consumed. + var onDownArrow: () -> Bool + /// Called when the view gains or loses first-responder status. + var onFocusChange: (Bool) -> Void + + // MARK: - NSViewRepresentable + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + + let tv = context.coordinator.textView + tv.delegate = context.coordinator + tv.isEditable = true + tv.isRichText = false + tv.drawsBackground = false + tv.backgroundColor = .clear + tv.isAutomaticQuoteSubstitutionEnabled = false + tv.isAutomaticDashSubstitutionEnabled = false + tv.isAutomaticSpellingCorrectionEnabled = true + tv.isContinuousSpellCheckingEnabled = true + tv.allowsUndo = true + tv.isVerticallyResizable = true + tv.isHorizontallyResizable = false + tv.autoresizingMask = [.width] + tv.textContainer?.widthTracksTextView = true + tv.textContainerInset = NSSize(width: 8, height: 6) + + scrollView.documentView = tv + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + let tv = context.coordinator.textView + let coord = context.coordinator + + // Update text only when it differs (avoids caret-jumping on every keystroke) + if tv.string != text { + let sel = tv.selectedRanges + tv.string = text + let len = (tv.string as NSString).length + tv.selectedRanges = sel.map { v in + let r = v.rangeValue + let loc = min(r.location, len) + let length = min(r.length, max(0, len - loc)) + return NSValue(range: NSRange(location: loc, length: length)) + } + } + + if tv.font != font { tv.font = font } + if tv.textColor != textColor { tv.textColor = textColor } + + // Keep coordinator callbacks current with each SwiftUI render + coord.textBinding = $text + coord.onReturn = onReturn + coord.onEscape = onEscape + coord.onUpArrow = onUpArrow + coord.onDownArrow = onDownArrow + coord.onFocusChange = onFocusChange + + if isFocused { + DispatchQueue.main.async { + guard let window = tv.window, window.firstResponder !== tv else { return } + window.makeFirstResponder(tv) + } + } + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + // MARK: - Coordinator + + final class Coordinator: NSObject, NSTextViewDelegate { + let textView = KeyableNSTextView() + + // Updated on every SwiftUI render via updateNSView + var textBinding: Binding? + var onReturn: () -> Bool = { false } + var onEscape: () -> Bool = { false } + var onUpArrow: () -> Bool = { false } + var onDownArrow: () -> Bool = { false } + var onFocusChange: (Bool) -> Void = { _ in } + + override init() { + super.init() + textView.coordinator = self + } + + func textDidChange(_ notification: Notification) { + guard let tv = notification.object as? NSTextView else { return } + textBinding?.wrappedValue = tv.string + } + } +} + +// MARK: - KeyableNSTextView + +/// NSTextView that routes Return / Escape / arrow keys to the SwiftUI +/// coordinator before the AppKit default handling runs. +final class KeyableNSTextView: NSTextView { + weak var coordinator: NativeTextEditor.Coordinator? + + override func keyDown(with event: NSEvent) { + guard let coord = coordinator else { super.keyDown(with: event); return } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let shift = flags.contains(.shift) + let cmd = flags.contains(.command) + + switch event.keyCode { + case 36: // Return + if shift || cmd { + // Shift+Enter or Cmd+Enter → literal newline + insertNewlineIgnoringFieldEditor(nil) + } else { + // Plain Enter → let SwiftUI decide (send or select dropdown item) + if !coord.onReturn() { + insertNewlineIgnoringFieldEditor(nil) + } + } + case 53: // Escape + if !coord.onEscape() { super.keyDown(with: event) } + case 126: // Up arrow + if !coord.onUpArrow() { super.keyDown(with: event) } + case 125: // Down arrow + if !coord.onDownArrow() { super.keyDown(with: event) } + default: + super.keyDown(with: event) + } + } + + override func becomeFirstResponder() -> Bool { + let ok = super.becomeFirstResponder() + if ok { coordinator?.onFocusChange(true) } + return ok + } + + override func resignFirstResponder() -> Bool { + let ok = super.resignFirstResponder() + if ok { coordinator?.onFocusChange(false) } + return ok + } +} diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift new file mode 100644 index 0000000..eb73e14 --- /dev/null +++ b/oAI/Views/Main/SidebarView.swift @@ -0,0 +1,216 @@ +// +// SidebarView.swift +// oAI +// +// Collapsible sidebar: new chat, conversation list, status pills +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Rune Olsen +// +// This file is part of oAI. +// +// oAI is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// oAI is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +// Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with oAI. If not, see . + + +import SwiftUI +#if os(macOS) +import AppKit +#endif + +struct SidebarView: View { + @Environment(ChatViewModel.self) private var chatViewModel + @State private var conversations: [Conversation] = [] + @State private var searchText = "" + + private var filteredConversations: [Conversation] { + guard !searchText.isEmpty else { return conversations } + return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) } + } + + var body: some View { + VStack(spacing: 0) { + // New Chat button + Button(action: { chatViewModel.newConversation() }) { + HStack(spacing: 8) { + Image(systemName: "square.and.pencil") + .font(.system(size: 14)) + Text("New Chat") + .font(.system(size: 14, weight: .medium)) + Spacer() + } + .foregroundColor(.oaiPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + // Search field + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + TextField("Search conversations…", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 13)) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + } + Divider().frame(height: 12) + Button { + chatViewModel.showConversations = true + } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Advanced search — semantic search, bulk delete, export") + } + .padding(7) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) + .padding(.horizontal, 8) + .padding(.bottom, 6) + + Divider() + + // Conversation list + if filteredConversations.isEmpty { + Spacer() + VStack(spacing: 8) { + Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass") + .font(.title2) + .foregroundStyle(.tertiary) + Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches") + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + } else { + List { + ForEach(filteredConversations) { conversation in + SidebarConversationRow(conversation: conversation) + .contentShape(Rectangle()) + .onTapGesture { + chatViewModel.loadConversation(conversation) + } + .listRowBackground( + chatViewModel.currentConversationName == conversation.name + ? Color.oaiAccent.opacity(0.15) + : Color.clear + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + deleteConversation(conversation) + } label: { + Label("Delete", systemImage: "trash") + } + Button { + renameConversation(conversation) + } label: { + Label("Rename", systemImage: "pencil") + } + .tint(.orange) + } + } + } + .listStyle(.sidebar) + } + + } + .onAppear { loadConversations() } + .onChange(of: chatViewModel.currentConversationName) { loadConversations() } + .onChange(of: chatViewModel.messages.count) { loadConversations() } + } + + private func loadConversations() { + conversations = (try? DatabaseService.shared.listConversations()) ?? [] + } + + private func deleteConversation(_ conversation: Conversation) { + _ = try? DatabaseService.shared.deleteConversation(id: conversation.id) + withAnimation { + conversations.removeAll { $0.id == conversation.id } + } + } + + private func renameConversation(_ conversation: Conversation) { + #if os(macOS) + let alert = NSAlert() + alert.messageText = "Rename Conversation" + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + input.stringValue = conversation.name + input.selectText(nil) + alert.accessoryView = input + alert.window.initialFirstResponder = input + guard alert.runModal() == .alertFirstButtonReturn else { return } + let newName = input.stringValue.trimmingCharacters(in: .whitespaces) + guard !newName.isEmpty, newName != conversation.name else { return } + do { + _ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil) + if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { + conversations[i].name = newName + conversations[i].updatedAt = Date() + } + chatViewModel.didRenameConversation(id: conversation.id, newName: newName) + } catch { + Log.db.error("Failed to rename conversation: \(error.localizedDescription)") + } + #endif + } +} + +// MARK: - Sidebar conversation row + +struct SidebarConversationRow: View { + let conversation: Conversation + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM.yyyy" + return formatter.string(from: conversation.updatedAt) + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(conversation.name) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + HStack(spacing: 4) { + Text("^[\(conversation.messageCount) message](inflect: true)") + .font(.system(size: 11)) + Text("·") + .font(.system(size: 11)) + Text(formattedDate) + .font(.system(size: 11)) + } + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } +} + +#Preview { + SidebarView() + .environment(ChatViewModel()) + .frame(width: 240, height: 600) +} diff --git a/oAI/Views/Screens/CombineConversationsSheet.swift b/oAI/Views/Screens/CombineConversationsSheet.swift new file mode 100644 index 0000000..03a0857 --- /dev/null +++ b/oAI/Views/Screens/CombineConversationsSheet.swift @@ -0,0 +1,192 @@ +// +// CombineConversationsSheet.swift +// oAI +// +// Combine 2+ saved conversations into one, optionally using AI to merge content +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Rune Olsen +// +// This file is part of oAI. +// +// oAI is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// oAI is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +// Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with oAI. If not, see . + + +import SwiftUI + +struct CombineConversationsSheet: View { + @Environment(\.dismiss) var dismiss + + let conversations: [Conversation] + var onCompleted: (Conversation) -> Void + + @State private var name: String + @State private var mode: CombineMode = .simple + @State private var deleteOriginals = false + @State private var isProcessing = false + @State private var errorMessage: String? + + private let settings = SettingsService.shared + + init(conversations: [Conversation], onCompleted: @escaping (Conversation) -> Void) { + self.conversations = conversations + self.onCompleted = onCompleted + let joined = conversations.map(\.name).joined(separator: " + ") + _name = State(initialValue: String(joined.prefix(80))) + } + + private var defaultModelLabel: String? { + guard let model = settings.defaultModel, !model.isEmpty else { return nil } + return "\(settings.defaultProvider.displayName) / \(model)" + } + + private var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty + && conversations.count >= 2 + && (mode == .simple || defaultModelLabel != nil) + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Combine Conversations") + .font(.system(size: 18, weight: .bold)) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2).foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .keyboardShortcut(.escape, modifiers: []) + .disabled(isProcessing) + } + .padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Combining \(conversations.count) conversations").font(.system(size: 13, weight: .semibold)) + VStack(alignment: .leading, spacing: 4) { + ForEach(conversations) { conversation in + Label("\(conversation.name) (\(conversation.messageCount) messages)", systemImage: "bubble.left.and.bubble.right") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("New conversation name").font(.system(size: 13, weight: .semibold)) + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + .disabled(isProcessing) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Merge method").font(.system(size: 13, weight: .semibold)) + Picker("", selection: $mode) { + Text("Simple Merge").tag(CombineMode.simple) + Text("AI-Assisted Merge").tag(CombineMode.ai) + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled(isProcessing) + + if mode == .simple { + Text("Messages from all selected conversations are combined in chronological order.") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("A model reads all the source messages and rewrites them into one coherent, de-duplicated conversation.") + .font(.caption).foregroundStyle(.secondary) + if let label = defaultModelLabel { + Label("Uses your default model: \(label)", systemImage: "cpu") + .font(.caption).foregroundStyle(.secondary) + } else { + Label("No default model configured — set one in Settings → General.", systemImage: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(.orange) + } + } + } + + Toggle("Delete original conversations after combining", isOn: $deleteOriginals) + .toggleStyle(.checkbox) + .disabled(isProcessing) + + if let errorMessage { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "xmark.octagon.fill").foregroundStyle(.red) + Text(errorMessage).font(.caption) + } + .padding(10) + .background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } + } + .padding(.horizontal, 24).padding(.vertical, 16) + } + + Divider() + + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.bordered) + .disabled(isProcessing) + Spacer() + if isProcessing { + ProgressView().controlSize(.small) + Text("Combining…").font(.caption).foregroundStyle(.secondary) + } + Button("Combine") { + combine() + } + .buttonStyle(.borderedProminent) + .disabled(!isValid || isProcessing) + .keyboardShortcut(.return, modifiers: [.command]) + } + .padding(.horizontal, 24).padding(.vertical, 12) + } + .frame(minWidth: 520, idealWidth: 560, minHeight: 460, idealHeight: 520) + } + + private func combine() { + isProcessing = true + errorMessage = nil + let ids = conversations.map(\.id) + let trimmedName = name.trimmingCharacters(in: .whitespaces) + let selectedMode = mode + let shouldDeleteOriginals = deleteOriginals + + Task { + do { + let newConversation = try await ConversationMergeService.merge( + conversationIds: ids, + name: trimmedName, + mode: selectedMode, + deleteOriginals: shouldDeleteOriginals + ) + await MainActor.run { + isProcessing = false + onCompleted(newConversation) + dismiss() + } + } catch { + await MainActor.run { + isProcessing = false + errorMessage = error.localizedDescription + } + } + } + } +} diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 5ba8fb2..c35707e 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -36,6 +36,7 @@ struct ConversationListView: View { @State private var semanticResults: [Conversation] = [] @State private var isSearching = false @State private var selectedIndex: Int = 0 + @State private var showCombineSheet = false @FocusState private var searchFocused: Bool private let settings = SettingsService.shared var onLoad: ((Conversation) -> Void)? @@ -70,6 +71,18 @@ struct ConversationListView: View { } .buttonStyle(.plain) + if selectedConversations.count >= 2 { + Button { + showCombineSheet = true + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.merge") + Text("Combine (\(selectedConversations.count))") + } + } + .buttonStyle(.plain) + } + if !selectedConversations.isEmpty { Button(role: .destructive) { deleteSelected() @@ -298,6 +311,16 @@ struct ConversationListView: View { searchFocused = true } .frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600) + .sheet(isPresented: $showCombineSheet) { + CombineConversationsSheet( + conversations: conversations.filter { selectedConversations.contains($0.id) }, + onCompleted: { _ in + loadConversations() + selectedConversations.removeAll() + isSelecting = false + } + ) + } } private func loadConversations() { diff --git a/oAI/Views/Screens/ModelInfoView.swift b/oAI/Views/Screens/ModelInfoView.swift index e785c76..aabce4a 100644 --- a/oAI/Views/Screens/ModelInfoView.swift +++ b/oAI/Views/Screens/ModelInfoView.swift @@ -30,6 +30,7 @@ struct ModelInfoView: View { @Environment(\.dismiss) var dismiss @Bindable private var settings = SettingsService.shared + @State private var isDescriptionExpanded = false var body: some View { VStack(spacing: 0) { @@ -78,8 +79,18 @@ struct ModelInfoView: View { Text(desc) .font(.body) .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(isDescriptionExpanded ? nil : 4) .textSelection(.enabled) + if desc.count > 250 { + Button(isDescriptionExpanded ? "Less" : "More…") { + withAnimation(.easeInOut(duration: 0.2)) { + isDescriptionExpanded.toggle() + } + } + .font(.callout) + .foregroundStyle(.blue) + .buttonStyle(.plain) + } } .padding(.leading, 4) } diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index abdd8b6..2717fe3 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -92,6 +92,8 @@ struct oAIApp: App { CommandGroup(after: .newItem) { Button("Open Chat…") { chatViewModel.showConversations = true } .keyboardShortcut("o", modifiers: .command) + Button("Search Conversations") { chatViewModel.showConversations = true } + .keyboardShortcut("l", modifiers: .command) } CommandGroup(replacing: .saveItem) { @@ -113,10 +115,44 @@ struct oAIApp: App { .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) } + // ── View menu ───────────────────────────────────────────────── + CommandMenu("View") { + Button("Select Model") { chatViewModel.showModelSelector = true } + .keyboardShortcut("m", modifiers: .command) + + Button("Model Info") { + chatViewModel.modelInfoTarget = chatViewModel.selectedModel + } + .keyboardShortcut("i", modifiers: .command) + .disabled(chatViewModel.selectedModel == nil) + + Divider() + + Button("Command History") { chatViewModel.showHistory = true } + .keyboardShortcut("h", modifiers: .command) + + Button("In-App Help") { chatViewModel.showHelp = true } + .keyboardShortcut("/", modifiers: .command) + + Button("Credits") { chatViewModel.showCredits = true } + + Divider() + + Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") { + chatViewModel.onlineMode.toggle() + } + .keyboardShortcut("o", modifiers: [.command, .shift]) + } + // ── Help menu ───────────────────────────────────────────────── CommandGroup(replacing: .help) { Button("oAI Help") { openHelp() } .keyboardShortcut("?", modifiers: .command) + Divider() + Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") { + UpdateCheckService.shared.checkForUpdatesManually() + } + .disabled(UpdateCheckService.shared.isCheckingManually) } } #endif