New version v2.3.6
This commit is contained in:
4750
oAI/Localizable.xcstrings
Normal file
4750
oAI/Localizable.xcstrings
Normal file
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,9 @@ struct Message: Identifiable, Codable, Equatable {
|
||||
// Tool call details (not persisted — in-memory only for expandable display)
|
||||
var toolCalls: [ToolCallDetail]? = nil
|
||||
|
||||
// Reasoning/thinking content (not persisted — in-memory only)
|
||||
var thinkingContent: String? = nil
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
role: MessageRole,
|
||||
|
||||
@@ -45,6 +45,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
|
||||
let tools: Bool // Function calling
|
||||
let online: Bool // Web search
|
||||
var imageGeneration: Bool = false // Image output
|
||||
var thinking: Bool = false // Reasoning/thinking tokens
|
||||
}
|
||||
|
||||
struct Architecture: Codable, Hashable {
|
||||
|
||||
@@ -61,6 +61,11 @@ struct ProviderCapabilities: Codable {
|
||||
|
||||
// MARK: - Chat Request
|
||||
|
||||
struct ReasoningConfig: Sendable {
|
||||
let effort: String // "high", "medium", "low", "minimal"
|
||||
let exclude: Bool // true = use reasoning internally, hide from response
|
||||
}
|
||||
|
||||
struct ChatRequest {
|
||||
let messages: [Message]
|
||||
let model: String
|
||||
@@ -72,6 +77,7 @@ struct ChatRequest {
|
||||
let tools: [Tool]?
|
||||
let onlineMode: Bool
|
||||
let imageGeneration: Bool
|
||||
let reasoning: ReasoningConfig?
|
||||
|
||||
init(
|
||||
messages: [Message],
|
||||
@@ -83,7 +89,8 @@ struct ChatRequest {
|
||||
systemPrompt: String? = nil,
|
||||
tools: [Tool]? = nil,
|
||||
onlineMode: Bool = false,
|
||||
imageGeneration: Bool = false
|
||||
imageGeneration: Bool = false,
|
||||
reasoning: ReasoningConfig? = nil
|
||||
) {
|
||||
self.messages = messages
|
||||
self.model = model
|
||||
@@ -95,6 +102,7 @@ struct ChatRequest {
|
||||
self.tools = tools
|
||||
self.onlineMode = onlineMode
|
||||
self.imageGeneration = imageGeneration
|
||||
self.reasoning = reasoning
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +182,14 @@ struct StreamChunk {
|
||||
let content: String?
|
||||
let role: String?
|
||||
let images: [Data]?
|
||||
let thinking: String? // reasoning/thinking tokens (nil if excluded or not supported)
|
||||
|
||||
init(content: String?, role: String?, images: [Data]? = nil, thinking: String? = nil) {
|
||||
self.content = content
|
||||
self.role = role
|
||||
self.images = images
|
||||
self.thinking = thinking
|
||||
}
|
||||
}
|
||||
|
||||
var deltaContent: String? {
|
||||
|
||||
@@ -27,6 +27,16 @@ import Foundation
|
||||
|
||||
// MARK: - API Request
|
||||
|
||||
struct ReasoningAPIConfig: Codable {
|
||||
let effort: String
|
||||
let exclude: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case effort
|
||||
case exclude
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenRouterChatRequest: Codable {
|
||||
let model: String
|
||||
let messages: [APIMessage]
|
||||
@@ -37,6 +47,7 @@ struct OpenRouterChatRequest: Codable {
|
||||
let tools: [Tool]?
|
||||
let toolChoice: String?
|
||||
let modalities: [String]?
|
||||
let reasoning: ReasoningAPIConfig?
|
||||
|
||||
struct APIMessage: Codable {
|
||||
let role: String
|
||||
@@ -126,6 +137,7 @@ struct OpenRouterChatRequest: Codable {
|
||||
case tools
|
||||
case toolChoice = "tool_choice"
|
||||
case modalities
|
||||
case reasoning
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +217,46 @@ struct OpenRouterStreamChunk: Codable {
|
||||
struct Delta: Codable {
|
||||
let role: String?
|
||||
let content: String?
|
||||
let reasoning: String?
|
||||
// images[] from top-level delta field (custom OpenRouter format)
|
||||
let images: [OpenRouterChatResponse.ImageOutput]?
|
||||
// images extracted from content[] array (standard OpenAI content-block format)
|
||||
let contentBlockImages: [OpenRouterChatResponse.ImageOutput]
|
||||
|
||||
private struct ContentBlock: Codable {
|
||||
let type: String
|
||||
let text: String?
|
||||
let imageUrl: OpenRouterChatResponse.ImageOutput.ImageURL?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type, text
|
||||
case imageUrl = "image_url"
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role, content, images, reasoning
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
role = try c.decodeIfPresent(String.self, forKey: .role)
|
||||
images = try c.decodeIfPresent([OpenRouterChatResponse.ImageOutput].self, forKey: .images)
|
||||
reasoning = try c.decodeIfPresent(String.self, forKey: .reasoning)
|
||||
// content can be a plain String OR an array of content blocks
|
||||
if let text = try? c.decodeIfPresent(String.self, forKey: .content) {
|
||||
content = text
|
||||
contentBlockImages = []
|
||||
} else if let blocks = try? c.decodeIfPresent([ContentBlock].self, forKey: .content) {
|
||||
content = blocks.compactMap { $0.text }.joined().nonEmptyOrNil
|
||||
contentBlockImages = blocks.compactMap { block in
|
||||
guard block.type == "image_url", let url = block.imageUrl else { return nil }
|
||||
return OpenRouterChatResponse.ImageOutput(imageUrl: url)
|
||||
}
|
||||
} else {
|
||||
content = nil
|
||||
contentBlockImages = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
|
||||
@@ -110,7 +110,8 @@ class OpenRouterProvider: AIProvider {
|
||||
return output.contains("image")
|
||||
}
|
||||
return false
|
||||
}()
|
||||
}(),
|
||||
thinking: modelData.supportedParameters?.contains("reasoning") ?? false
|
||||
),
|
||||
architecture: modelData.architecture.map { arch in
|
||||
ModelInfo.Architecture(
|
||||
@@ -368,6 +369,10 @@ class OpenRouterProvider: AIProvider {
|
||||
effectiveModel = request.model
|
||||
}
|
||||
|
||||
let reasoningConfig: ReasoningAPIConfig? = request.reasoning.map {
|
||||
ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil)
|
||||
}
|
||||
|
||||
return OpenRouterChatRequest(
|
||||
model: effectiveModel,
|
||||
messages: apiMessages,
|
||||
@@ -377,7 +382,8 @@ class OpenRouterProvider: AIProvider {
|
||||
topP: request.topP,
|
||||
tools: request.tools,
|
||||
toolChoice: request.tools != nil ? "auto" : nil,
|
||||
modalities: request.imageGeneration ? ["text", "image"] : nil
|
||||
modalities: request.imageGeneration ? ["text", "image"] : nil,
|
||||
reasoning: reasoningConfig
|
||||
)
|
||||
}
|
||||
|
||||
@@ -416,7 +422,11 @@ class OpenRouterProvider: AIProvider {
|
||||
throw ProviderError.invalidResponse
|
||||
}
|
||||
|
||||
let images = choice.delta.images.flatMap { decodeImageOutputs($0) }
|
||||
// Merge images from both sources: top-level `images` field and content-block images
|
||||
let topLevelImages = choice.delta.images.flatMap { decodeImageOutputs($0) } ?? []
|
||||
let blockImages = decodeImageOutputs(choice.delta.contentBlockImages) ?? []
|
||||
let allImages = topLevelImages + blockImages
|
||||
let images: [Data]? = allImages.isEmpty ? nil : allImages
|
||||
|
||||
return StreamChunk(
|
||||
id: apiChunk.id,
|
||||
@@ -424,7 +434,8 @@ class OpenRouterProvider: AIProvider {
|
||||
delta: StreamChunk.Delta(
|
||||
content: choice.delta.content,
|
||||
role: choice.delta.role,
|
||||
images: images
|
||||
images: images,
|
||||
thinking: choice.delta.reasoning
|
||||
),
|
||||
finishReason: choice.finishReason,
|
||||
usage: apiChunk.usage.map { usage in
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
<li><a href="#email-handler">Email Handler (AI Assistant)</a></li>
|
||||
<li><a href="#shortcuts">Shortcuts (Prompt Templates)</a></li>
|
||||
<li><a href="#agent-skills">Agent Skills (SKILL.md)</a></li>
|
||||
<li><a href="#bash-execution">Bash Execution</a></li>
|
||||
<li><a href="#icloud-backup">iCloud Backup</a></li>
|
||||
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
|
||||
<li><a href="#keyboard-shortcuts">Keyboard Shortcuts</a></li>
|
||||
<li><a href="#settings">Settings</a></li>
|
||||
<li><a href="#system-prompts">System Prompts</a></li>
|
||||
@@ -45,7 +48,7 @@
|
||||
<!-- Getting Started -->
|
||||
<section id="getting-started">
|
||||
<h2>Getting Started</h2>
|
||||
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama.</p>
|
||||
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama. The app is available in English, Norwegian Bokmål, Swedish, Danish, and German — it follows your macOS language preference automatically.</p>
|
||||
|
||||
<div class="steps">
|
||||
<h3>Quick Start</h3>
|
||||
@@ -101,10 +104,32 @@
|
||||
<li>Click the model name in the header</li>
|
||||
</ul>
|
||||
|
||||
<h3>Searching & Filtering</h3>
|
||||
<p>Type in the search bar to filter by model name, ID, or description. Quick-filter buttons narrow results by capability:</p>
|
||||
<ul>
|
||||
<li><strong>👁️ Vision</strong> — models that can read images</li>
|
||||
<li><strong>🔧 Tools</strong> — models that can call tools (MCP, web search, bash)</li>
|
||||
<li><strong>🌐 Online</strong> — models with built-in web search</li>
|
||||
<li><strong>🎨 Image Gen</strong> — models that generate images</li>
|
||||
<li><strong>🧠 Thinking</strong> — models that support reasoning / thinking tokens</li>
|
||||
</ul>
|
||||
|
||||
<h3>Sorting</h3>
|
||||
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
|
||||
<ul>
|
||||
<li><strong>Default</strong> — provider order</li>
|
||||
<li><strong>Price: Low to High</strong> — cheapest per million tokens first</li>
|
||||
<li><strong>Price: High to Low</strong> — most capable/expensive first</li>
|
||||
<li><strong>Context: High to Low</strong> — largest context window first</li>
|
||||
</ul>
|
||||
|
||||
<h3>Model Information</h3>
|
||||
<p>View details about any model:</p>
|
||||
<p>Click the <strong>ⓘ</strong> icon on any model row to open a full details sheet — context length, pricing, capabilities, and description — without selecting that model. You can also type:</p>
|
||||
<code class="command">/info</code>
|
||||
<p class="note">Shows information about the currently selected model including context length, pricing, and capabilities.</p>
|
||||
<p class="note">Shows information about the currently selected model.</p>
|
||||
|
||||
<h3>Keyboard Navigation</h3>
|
||||
<p>Use <kbd>↑</kbd> / <kbd>↓</kbd> to move through the list, <kbd>Return</kbd> to select the highlighted model.</p>
|
||||
|
||||
<h3>Default Model</h3>
|
||||
<p>Your selected model is automatically saved and will be restored when you restart the app.</p>
|
||||
@@ -133,6 +158,9 @@
|
||||
<p>If you're not satisfied with a response:</p>
|
||||
<code class="command">/retry</code>
|
||||
<p class="note">Resends your last message to generate a new response.</p>
|
||||
|
||||
<h3>Tool Call Inspection</h3>
|
||||
<p>When the AI uses tools (file access, web search, bash commands), a <strong>🔧 Calling: …</strong> system message appears in the chat. Click it to expand an inline view showing each tool's input arguments and result as pretty-printed JSON. A spinner indicates a pending tool call; a green checkmark shows when it completes.</p>
|
||||
</section>
|
||||
|
||||
<!-- File Attachments -->
|
||||
@@ -408,10 +436,14 @@
|
||||
<h3>Setting Up MCP</h3>
|
||||
<ol>
|
||||
<li>Enable MCP: <code>/mcp on</code></li>
|
||||
<li>Add a folder: <code>/mcp add ~/Projects/myapp</code></li>
|
||||
<li>Add a folder: <code>/mcp add ~/Projects/myapp</code>, click <strong>+ Add Folder…</strong> in Settings → MCP, or drag a folder from Finder onto the allowed folders list</li>
|
||||
<li>Ask the AI questions about your files</li>
|
||||
</ol>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Drag & Drop:</strong> You can drag one or more folders directly from Finder onto the allowed folders area in Settings → MCP. The list highlights when you drag over it, and multiple folders can be dropped at once.
|
||||
</div>
|
||||
|
||||
<h3>What the AI Can Do</h3>
|
||||
<p><strong>Read permissions (always enabled when MCP is on):</strong></p>
|
||||
<ul>
|
||||
@@ -456,6 +488,20 @@
|
||||
<code class="command">/save my-project-chat</code>
|
||||
<p class="note">Saves all current messages under the specified name.</p>
|
||||
|
||||
<p>From the <strong>File menu</strong> you also have:</p>
|
||||
<ul>
|
||||
<li><strong>Save Chat (<kbd>⌘S</kbd>)</strong> — Re-saves under the current name, or prompts for a name if the conversation hasn't been saved yet.</li>
|
||||
<li><strong>Save Chat As…</strong> — Always prompts for a new name and creates a fresh copy, switching the session to that copy. Useful for branching a conversation.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Renaming Conversations</h3>
|
||||
<p>In the Conversations list (<kbd>⌘L</kbd>):</p>
|
||||
<ul>
|
||||
<li>Click the <strong>✏️ pencil icon</strong> next to any conversation to rename it inline.</li>
|
||||
<li>Swipe left on a conversation row to reveal <strong>Rename</strong>, <strong>Export</strong>, and <strong>Delete</strong> actions.</li>
|
||||
</ul>
|
||||
<p class="note">If the renamed conversation is currently open, the session name updates immediately so <kbd>⌘S</kbd> saves under the new name.</p>
|
||||
|
||||
<h3>Loading Conversations</h3>
|
||||
<code class="command">/load</code>
|
||||
<p class="note">Opens a list of saved conversations. Select one to load.</p>
|
||||
@@ -1155,6 +1201,165 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bash Execution -->
|
||||
<section id="bash-execution">
|
||||
<h2>Bash Execution</h2>
|
||||
<p>When enabled, the AI can run shell commands directly on your Mac via <code>/bin/zsh</code>. This lets it read output, run scripts, compile code, check system state, and more — all without leaving the chat.</p>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Security:</strong> Bash execution gives the AI the ability to run arbitrary commands on your machine. Only enable it when you need it, and use the approval prompt to review each command before it runs.
|
||||
</div>
|
||||
|
||||
<h3>Enabling Bash Execution</h3>
|
||||
<ol>
|
||||
<li>Press <kbd>⌘,</kbd> to open Settings</li>
|
||||
<li>Go to the <strong>MCP</strong> tab</li>
|
||||
<li>Scroll to the <strong>Bash Execution</strong> section</li>
|
||||
<li>Toggle <strong>Enable Bash Execution</strong> on</li>
|
||||
</ol>
|
||||
|
||||
<div class="note">
|
||||
<strong>Note:</strong> Bash execution is disabled by default and must be explicitly turned on. It is independent of MCP — you do not need MCP folders configured to use it.
|
||||
</div>
|
||||
|
||||
<h3>Approval Prompt</h3>
|
||||
<p>When <strong>Require Approval</strong> is enabled (the default), a sheet appears before each command showing:</p>
|
||||
<ul>
|
||||
<li>The full command to be run</li>
|
||||
<li>The working directory</li>
|
||||
<li>A security warning</li>
|
||||
</ul>
|
||||
<p>You can then choose:</p>
|
||||
<ul>
|
||||
<li><strong>Allow Once</strong> — run this command and ask again next time</li>
|
||||
<li><strong>Allow for Session</strong> — skip the approval prompt for the rest of this chat</li>
|
||||
<li><strong>Deny</strong> — cancel the command; the AI is told it was rejected</li>
|
||||
</ul>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Session Scope:</strong> "Allow for Session" resets when you start a new chat, switch models, or load a saved conversation. It is never permanent.
|
||||
</div>
|
||||
|
||||
<h3>Configuration Options</h3>
|
||||
<dl class="commands">
|
||||
<dt>Working Directory</dt>
|
||||
<dd>The directory commands run in (default: <code>~</code>). Set to your project folder to avoid needing full paths.</dd>
|
||||
|
||||
<dt>Timeout</dt>
|
||||
<dd>Maximum seconds a command can run before being killed (default: 30 s). Increase for long-running builds.</dd>
|
||||
|
||||
<dt>Require Approval</dt>
|
||||
<dd>Show the approval sheet before each command (default: on). Disable only if you fully trust the AI's judgment for the current session.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Output Limits</h3>
|
||||
<p>To keep the context manageable, output is capped:</p>
|
||||
<ul>
|
||||
<li><strong>stdout</strong>: first 20,000 characters</li>
|
||||
<li><strong>stderr</strong>: first 5,000 characters</li>
|
||||
</ul>
|
||||
<p>If output is truncated, the AI sees a note indicating how much was omitted.</p>
|
||||
|
||||
<h3>Example Usage</h3>
|
||||
<div class="example">
|
||||
<p><strong>You:</strong> "Run the test suite and tell me what's failing."</p>
|
||||
<p><strong>AI:</strong> <em>(bash approval sheet appears with <code>cd ~/myproject && swift test</code>)</em></p>
|
||||
<p><strong>After Allow:</strong> "3 tests failed: testLogin, testSignup, testLogout. The error in testLogin is…"</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- iCloud Backup -->
|
||||
<section id="icloud-backup">
|
||||
<h2>iCloud Backup</h2>
|
||||
<p>Back up and restore all your oAI settings with one click. Backups are saved to iCloud Drive so they're available on any Mac where you're signed in.</p>
|
||||
|
||||
<div class="note">
|
||||
<strong>What is included:</strong> All settings and preferences — providers, model defaults, MCP configuration, appearance, advanced options, shortcuts, skills, and more.<br><br>
|
||||
<strong>What is excluded:</strong> API keys, passwords, and other credentials. These are intentionally left out for security and must be re-entered after restoring on a new machine.
|
||||
</div>
|
||||
|
||||
<h3>Backing Up</h3>
|
||||
<ol>
|
||||
<li>Press <kbd>⌘,</kbd> to open Settings</li>
|
||||
<li>Go to the <strong>Backup</strong> tab</li>
|
||||
<li>Click <strong>Back Up Now</strong></li>
|
||||
</ol>
|
||||
<p>The backup file is saved to <code>~/iCloud Drive/oAI/oai_backup.json</code>. If iCloud Drive is not available, it falls back to your Downloads folder. The date of the last backup is shown in the tab.</p>
|
||||
|
||||
<h3>Restoring</h3>
|
||||
<ol>
|
||||
<li>Open Settings → <strong>Backup</strong> tab</li>
|
||||
<li>Click <strong>Restore from File…</strong></li>
|
||||
<li>Select your <code>oai_backup.json</code> file</li>
|
||||
<li>Settings are applied immediately — no restart required</li>
|
||||
<li>Re-enter your API keys in Settings → <strong>General</strong></li>
|
||||
</ol>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 New Mac Setup:</strong> Back up on your old Mac, sign in to iCloud on your new Mac, open oAI, restore from the backup file — and all your settings are restored in seconds. You only need to re-enter API keys.
|
||||
</div>
|
||||
|
||||
<h3>Backup Format</h3>
|
||||
<p>Backups are plain JSON files (versioned for forward compatibility). You can inspect them in any text editor. The format is designed to be safe to share — no credentials are ever included.</p>
|
||||
</section>
|
||||
|
||||
<!-- Reasoning / Thinking Tokens -->
|
||||
<section id="reasoning">
|
||||
<h2>Reasoning / Thinking Tokens</h2>
|
||||
<p>Some AI models can "think out loud" before giving their final answer — reasoning through the problem step by step. oAI streams this thinking content live and displays it in a collapsible block above the response.</p>
|
||||
|
||||
<h3>Supported Models</h3>
|
||||
<p>Reasoning is available on models that support extended thinking, including:</p>
|
||||
<ul>
|
||||
<li>DeepSeek R1 and DeepSeek R1 variants (via OpenRouter)</li>
|
||||
<li>Qwen thinking models (via OpenRouter)</li>
|
||||
<li>Claude 3.7+ with extended thinking (via Anthropic or OpenRouter)</li>
|
||||
<li>OpenAI o1, o3, and similar reasoning models</li>
|
||||
<li>Grok reasoning models (via OpenRouter)</li>
|
||||
</ul>
|
||||
<p>Look for the <strong>🧠</strong> badge in the model selector to identify thinking-capable models. Use the <strong>🧠 Thinking</strong> quick-filter to show only reasoning models.</p>
|
||||
|
||||
<h3>Enabling Reasoning</h3>
|
||||
<ol>
|
||||
<li>Press <kbd>⌘,</kbd> to open Settings</li>
|
||||
<li>Go to the <strong>General</strong> tab → <strong>Features</strong> section</li>
|
||||
<li>Toggle <strong>Reasoning</strong> on</li>
|
||||
</ol>
|
||||
<p class="note">Reasoning only activates when the selected model supports extended thinking (🧠 badge). The setting has no effect on standard models.</p>
|
||||
|
||||
<h3>Effort Level</h3>
|
||||
<p>The effort level controls how many tokens the model spends on reasoning (as a share of its total token budget):</p>
|
||||
<dl class="commands">
|
||||
<dt>High (~80%)</dt>
|
||||
<dd>Deep, methodical reasoning. Best for math, logic, multi-step planning, and complex coding tasks. More expensive.</dd>
|
||||
|
||||
<dt>Medium (~50%)</dt>
|
||||
<dd>Balanced reasoning. Good for most tasks that benefit from thinking (default).</dd>
|
||||
|
||||
<dt>Low (~20%)</dt>
|
||||
<dd>Light reasoning pass. Suitable for moderately complex questions without a large token budget.</dd>
|
||||
|
||||
<dt>Minimal (~10%)</dt>
|
||||
<dd>Minimal internal thinking — just a quick check before answering. Fastest and cheapest.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Hiding Reasoning Content</h3>
|
||||
<p>Enable <strong>Hide reasoning content</strong> in Settings → General → Features to keep reasoning internal. The model still thinks at the selected effort level, but the thinking block is not shown — only the final answer appears in chat.</p>
|
||||
|
||||
<h3>In-Chat Experience</h3>
|
||||
<p>When reasoning is enabled and a thinking-capable model responds:</p>
|
||||
<ol>
|
||||
<li>A <strong>🧠 Thinking…</strong> block appears above the response and auto-expands</li>
|
||||
<li>Reasoning content streams in live as the model thinks</li>
|
||||
<li>When the final answer starts arriving, the thinking block collapses automatically</li>
|
||||
<li>Click the block header at any time to manually expand or collapse it</li>
|
||||
</ol>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Tip:</strong> Medium effort is a great default. Switch to High for math problems, deep code analysis, or anything requiring careful step-by-step reasoning.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Keyboard Shortcuts -->
|
||||
<section id="keyboard-shortcuts">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
@@ -1183,6 +1388,9 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<dt><kbd>⌘K</kbd></dt>
|
||||
<dd>Clear Chat</dd>
|
||||
|
||||
<dt><kbd>⌘S</kbd></dt>
|
||||
<dd>Save Chat (re-saves if already named, prompts for name otherwise)</dd>
|
||||
|
||||
<dt><kbd>⇧⌘S</kbd></dt>
|
||||
<dd>Show Statistics</dd>
|
||||
</dl>
|
||||
@@ -1211,18 +1419,25 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<h2>Settings</h2>
|
||||
<p>Customize oAI to your preferences. Press <kbd>⌘,</kbd> to open Settings.</p>
|
||||
|
||||
<h3>Providers Tab</h3>
|
||||
<h3>General Tab</h3>
|
||||
<ul>
|
||||
<li>Add and manage API keys for different providers</li>
|
||||
<li>Switch between providers</li>
|
||||
<li>Set default models</li>
|
||||
<li>Add and manage API keys for all providers (OpenAI, Anthropic, OpenRouter, Google, Ollama)</li>
|
||||
<li>Switch between providers and set the default model</li>
|
||||
<li>Configure streaming, memory, online mode, and max tokens</li>
|
||||
<li><strong>Features</strong>:
|
||||
<ul>
|
||||
<li><strong>Reasoning</strong> — enable thinking tokens, set effort level (High / Medium / Low / Minimal), optionally hide reasoning content from chat (see <a href="#reasoning">Reasoning / Thinking Tokens</a>)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>MCP Tab</h3>
|
||||
<ul>
|
||||
<li>Manage folder access permissions</li>
|
||||
<li>Enable/disable write operations</li>
|
||||
<li>Enable/disable MCP file access</li>
|
||||
<li>Add folders via the <strong>+ Add Folder…</strong> button, or drag from Finder</li>
|
||||
<li>Enable/disable write, delete, move, and bash execution permissions</li>
|
||||
<li>Configure gitignore respect</li>
|
||||
<li><strong>Bash Execution</strong> — enable AI shell access, set working directory, timeout, and approval behaviour (see <a href="#bash-execution">Bash Execution</a>)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Sync Tab</h3>
|
||||
@@ -1294,6 +1509,16 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<strong>⚠️ Security Note:</strong> All email credentials are encrypted with AES-256-GCM. For Gmail, use app-specific passwords (not your main password). Keep your subject identifier private to prevent abuse.
|
||||
</div>
|
||||
|
||||
<h3>Paperless Tab <span style="font-size: 0.75em; background: #f90; color: #fff; border-radius: 4px; padding: 1px 5px; vertical-align: middle;">Beta</span></h3>
|
||||
<p>Connect oAI to a self-hosted <a href="https://docs.paperless-ngx.com" target="_blank">Paperless-NGX</a> instance so the AI can search and read your document archive.</p>
|
||||
<ul>
|
||||
<li><strong>URL</strong> — Base URL of your Paperless instance (e.g. <code>https://paperless.yourdomain.com</code>)</li>
|
||||
<li><strong>API Token</strong> — Found in Paperless → Settings → API Tokens</li>
|
||||
<li><strong>Test Connection</strong> — Verify connectivity and display document count</li>
|
||||
</ul>
|
||||
<p>When enabled, the AI can use <code>paperless_search</code>, <code>paperless_get_document</code>, <code>paperless_list_tags</code>, and other tools to interact with your document library.</p>
|
||||
<p class="note"><strong>Beta:</strong> Paperless integration is under active development. Some features may be incomplete or behave unexpectedly.</p>
|
||||
|
||||
<h3>Shortcuts Tab</h3>
|
||||
<p>Create and manage personal prompt template commands. See <a href="#shortcuts">Shortcuts</a> section for full details.</p>
|
||||
|
||||
@@ -1312,9 +1537,17 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<li>Set maximum tokens (response length limit)</li>
|
||||
<li>Adjust temperature (creativity vs focus)</li>
|
||||
<li>Configure system prompts (see below)</li>
|
||||
<li><strong>Smart Context Selection</strong> - Intelligently select relevant messages to reduce token usage</li>
|
||||
<li><strong>Semantic Search</strong> - Enable AI-powered conversation search using embeddings</li>
|
||||
<li><strong>Progressive Summarization</strong> - Automatically summarize old portions of long conversations</li>
|
||||
<li><strong>Smart Context Selection</strong> — Intelligently select relevant messages to reduce token usage</li>
|
||||
<li><strong>Semantic Search</strong> — Enable AI-powered conversation search using embeddings</li>
|
||||
<li><strong>Progressive Summarization</strong> — Automatically summarize old portions of long conversations</li>
|
||||
</ul>
|
||||
|
||||
<h3>Backup Tab</h3>
|
||||
<p>One-click backup and restore of all settings to iCloud Drive. See <a href="#icloud-backup">iCloud Backup</a> for full details.</p>
|
||||
<ul>
|
||||
<li><strong>Back Up Now</strong> — exports settings to <code>~/iCloud Drive/oAI/oai_backup.json</code></li>
|
||||
<li><strong>Restore from File…</strong> — imports settings from a backup file</li>
|
||||
<li>API keys and credentials are excluded from backups and must be re-entered after restore</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -410,7 +410,7 @@ class AnytypeMCPService {
|
||||
updated[idx] = line
|
||||
let newMarkdown = updated.joined(separator: "\n")
|
||||
|
||||
let patchResult = try await request(
|
||||
_ = try await request(
|
||||
endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)",
|
||||
method: "PATCH",
|
||||
body: ["markdown": newMarkdown]
|
||||
|
||||
@@ -59,7 +59,7 @@ class IMAPClient {
|
||||
|
||||
let hostName = self.host
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
final class ResumeOnce {
|
||||
final class ResumeOnce: @unchecked Sendable {
|
||||
var resumed = false
|
||||
let lock = NSLock()
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class SMTPClient {
|
||||
|
||||
let hostName = self.host
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
final class ResumeOnce {
|
||||
final class ResumeOnce: @unchecked Sendable {
|
||||
var resumed = false
|
||||
let lock = NSLock()
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class SMTPClient {
|
||||
|
||||
let hostName = self.host
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
final class ResumeOnce {
|
||||
final class ResumeOnce: @unchecked Sendable {
|
||||
var resumed = false
|
||||
let lock = NSLock()
|
||||
}
|
||||
@@ -251,7 +251,7 @@ class SMTPClient {
|
||||
self.connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params)
|
||||
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
final class ResumeOnce {
|
||||
final class ResumeOnce: @unchecked Sendable {
|
||||
var resumed = false
|
||||
let lock = NSLock()
|
||||
}
|
||||
|
||||
@@ -222,6 +222,34 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reasoning Settings
|
||||
|
||||
var reasoningEnabled: Bool {
|
||||
get { cache["reasoningEnabled"] == "true" }
|
||||
set {
|
||||
cache["reasoningEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "reasoningEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// "high", "medium", "low", "minimal" — default "medium"
|
||||
var reasoningEffort: String {
|
||||
get { cache["reasoningEffort"] ?? "medium" }
|
||||
set {
|
||||
cache["reasoningEffort"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "reasoningEffort", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// When true, model reasons internally but thinking content is excluded from response
|
||||
var reasoningExclude: Bool {
|
||||
get { cache["reasoningExclude"] == "true" }
|
||||
set {
|
||||
cache["reasoningExclude"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "reasoningExclude", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Text Size Settings
|
||||
|
||||
/// GUI text size (headers, labels, buttons) — default 13
|
||||
|
||||
@@ -93,6 +93,13 @@ extension String {
|
||||
return max(1, count / 4)
|
||||
}
|
||||
|
||||
// MARK: - Nil coalescing helpers
|
||||
|
||||
/// Returns nil if the string is empty, otherwise self.
|
||||
var nonEmptyOrNil: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
|
||||
// MARK: - Truncation
|
||||
|
||||
func truncated(to length: Int, trailing: String = "...") -> String {
|
||||
|
||||
@@ -640,7 +640,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
case "/memory":
|
||||
if let arg = args.first?.lowercased() {
|
||||
memoryEnabled = arg == "on"
|
||||
showSystemMessage("Memory \(memoryEnabled ? "enabled" : "disabled")")
|
||||
showSystemMessage(memoryEnabled ? "Memory enabled" : "Memory disabled")
|
||||
} else {
|
||||
showSystemMessage("Usage: /memory on|off")
|
||||
}
|
||||
@@ -648,7 +648,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
case "/online":
|
||||
if let arg = args.first?.lowercased() {
|
||||
onlineMode = arg == "on"
|
||||
showSystemMessage("Online mode \(onlineMode ? "enabled" : "disabled")")
|
||||
showSystemMessage(onlineMode ? "Online mode enabled" : "Online mode disabled")
|
||||
} else {
|
||||
showSystemMessage("Usage: /online on|off")
|
||||
}
|
||||
@@ -881,6 +881,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
finalSystemPrompt = summariesText + "\n\n---\n\n" + effectiveSystemPrompt
|
||||
}
|
||||
|
||||
let reasoningConfig: ReasoningConfig? = {
|
||||
guard settings.reasoningEnabled,
|
||||
selectedModel?.capabilities.thinking == true,
|
||||
!isImageGen else { return nil }
|
||||
return ReasoningConfig(effort: settings.reasoningEffort, exclude: settings.reasoningExclude)
|
||||
}()
|
||||
|
||||
let chatRequest = ChatRequest(
|
||||
messages: contextWindow.messages,
|
||||
model: modelId,
|
||||
@@ -891,7 +898,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
systemPrompt: finalSystemPrompt,
|
||||
tools: nil,
|
||||
onlineMode: onlineMode,
|
||||
imageGeneration: isImageGen
|
||||
imageGeneration: isImageGen,
|
||||
reasoning: reasoningConfig
|
||||
)
|
||||
|
||||
if isImageGen {
|
||||
@@ -934,6 +942,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
} else {
|
||||
// Regular text: stream response
|
||||
var fullContent = ""
|
||||
var fullThinking = ""
|
||||
var collectedImages: [Data] = []
|
||||
var totalTokens: ChatResponse.Usage? = nil
|
||||
var wasCancelled = false
|
||||
|
||||
@@ -943,6 +953,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
break
|
||||
}
|
||||
|
||||
if let thinking = chunk.delta.thinking {
|
||||
fullThinking += thinking
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
messages[index].thinkingContent = fullThinking
|
||||
}
|
||||
}
|
||||
|
||||
if let content = chunk.deltaContent {
|
||||
fullContent += content
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
@@ -950,6 +967,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
}
|
||||
}
|
||||
|
||||
if let images = chunk.delta.images {
|
||||
collectedImages.append(contentsOf: images)
|
||||
}
|
||||
|
||||
if let usage = chunk.usage {
|
||||
totalTokens = usage
|
||||
}
|
||||
@@ -968,6 +989,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
messages[index].isStreaming = false
|
||||
messages[index].responseTime = responseTime
|
||||
messages[index].wasInterrupted = wasCancelled
|
||||
if !collectedImages.isEmpty {
|
||||
messages[index].generatedImages = collectedImages
|
||||
}
|
||||
|
||||
if let usage = totalTokens {
|
||||
messages[index].tokens = usage.completionTokens
|
||||
@@ -1151,7 +1175,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
mcpEnabled = true
|
||||
settings.mcpEnabled = true
|
||||
mcpStatus = "MCP"
|
||||
showSystemMessage("MCP enabled (\(mcp.allowedFolders.count) folder\(mcp.allowedFolders.count == 1 ? "" : "s") registered)")
|
||||
showSystemMessage("MCP enabled (^[\(mcp.allowedFolders.count) folder](inflect: true) registered)")
|
||||
|
||||
case "off":
|
||||
mcpEnabled = false
|
||||
@@ -1165,7 +1189,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
if let error = mcp.addFolder(path) {
|
||||
showSystemMessage("MCP: \(error)")
|
||||
} else {
|
||||
showSystemMessage("MCP: Added folder — \(mcp.allowedFolders.count) folder\(mcp.allowedFolders.count == 1 ? "" : "s") registered")
|
||||
showSystemMessage("MCP: Added folder — ^[\(mcp.allowedFolders.count) folder](inflect: true) registered")
|
||||
}
|
||||
} else {
|
||||
showSystemMessage("Usage: /mcp add <path>")
|
||||
@@ -1230,7 +1254,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
if settings.mcpCanCreateDirectories { perms.append("mkdir") }
|
||||
if settings.mcpCanMoveFiles { perms.append("move/copy") }
|
||||
let permStr = perms.isEmpty ? "read-only" : "read + \(perms.joined(separator: ", "))"
|
||||
showSystemMessage("MCP: \(enabled), \(folders) folder\(folders == 1 ? "" : "s"), \(permStr), gitignore: \(settings.mcpRespectGitignore ? "on" : "off")")
|
||||
showSystemMessage("MCP: \(enabled), ^[\(folders) folder](inflect: true), \(permStr), gitignore: \(settings.mcpRespectGitignore ? "on" : "off")")
|
||||
|
||||
default:
|
||||
showSystemMessage("MCP subcommands: on, off, status, add, remove, list, write")
|
||||
@@ -1525,10 +1549,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func showSystemMessage(_ text: String) -> UUID {
|
||||
private func showSystemMessage(_ text: String.LocalizationValue) -> UUID {
|
||||
let message = Message(
|
||||
role: .system,
|
||||
content: text,
|
||||
content: String(localized: text),
|
||||
tokens: nil,
|
||||
cost: nil,
|
||||
timestamp: Date(),
|
||||
|
||||
@@ -200,7 +200,7 @@ struct ContentView: View {
|
||||
|
||||
// Helper view for toolbar labels
|
||||
struct ToolbarLabel: View {
|
||||
let title: String
|
||||
let title: LocalizedStringKey
|
||||
let systemImage: String
|
||||
let showLabels: Bool
|
||||
let scale: Image.Scale
|
||||
|
||||
@@ -47,19 +47,19 @@ struct FooterView: View {
|
||||
HStack(spacing: 16) {
|
||||
FooterItem(
|
||||
icon: "message",
|
||||
label: "Messages",
|
||||
label: "Messages:",
|
||||
value: "\(stats.messageCount)"
|
||||
)
|
||||
|
||||
FooterItem(
|
||||
icon: "chart.bar.xaxis",
|
||||
label: "Tokens",
|
||||
label: "Tokens:",
|
||||
value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)"
|
||||
)
|
||||
|
||||
FooterItem(
|
||||
icon: "dollarsign.circle",
|
||||
label: "Cost",
|
||||
label: "Cost:",
|
||||
value: stats.totalCostDisplay
|
||||
)
|
||||
|
||||
@@ -134,7 +134,7 @@ struct SaveIndicator: View {
|
||||
return .secondary
|
||||
}
|
||||
|
||||
private var tooltip: String {
|
||||
private var tooltip: LocalizedStringKey {
|
||||
if isModified { return "Click to re-save \"\(conversationName ?? "")\"" }
|
||||
if isSaved { return "Saved — no changes" }
|
||||
return "Not saved — use /save <name>"
|
||||
@@ -162,7 +162,7 @@ struct SaveIndicator: View {
|
||||
|
||||
struct FooterItem: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let label: LocalizedStringKey
|
||||
let value: String
|
||||
private let guiSize = SettingsService.shared.guiTextSize
|
||||
|
||||
@@ -172,7 +172,7 @@ struct FooterItem: View {
|
||||
.font(.system(size: guiSize - 2))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
|
||||
Text(label + ":")
|
||||
Text(label)
|
||||
.font(.system(size: guiSize - 2))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
|
||||
@@ -187,9 +187,24 @@ struct SyncStatusFooter: View {
|
||||
private let gitSync = GitSyncService.shared
|
||||
private let settings = SettingsService.shared
|
||||
private let guiSize = SettingsService.shared.guiTextSize
|
||||
@State private var syncText = "Not Synced"
|
||||
|
||||
private enum SyncState { case off, notInitialized, ready, syncing, error, synced(Date) }
|
||||
@State private var syncState: SyncState = .off
|
||||
@State private var syncColor: Color = .secondary
|
||||
|
||||
private var syncText: LocalizedStringKey {
|
||||
switch syncState {
|
||||
case .off: return "Sync: Off"
|
||||
case .notInitialized: return "Sync: Not Initialized"
|
||||
case .ready: return "Sync: Ready"
|
||||
case .syncing: return "Syncing..."
|
||||
case .error: return "Sync Error"
|
||||
case .synced(let date):
|
||||
let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now)
|
||||
return "Last Sync: \(rel)"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
@@ -200,61 +215,27 @@ struct SyncStatusFooter: View {
|
||||
.font(.system(size: guiSize - 2, weight: .medium))
|
||||
.foregroundColor(syncColor)
|
||||
}
|
||||
.onAppear {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.syncStatus.lastSyncTime) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.syncStatus.isCloned) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.lastSyncError) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.isSyncing) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: settings.syncConfigured) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onAppear { updateSyncStatus() }
|
||||
.onChange(of: gitSync.syncStatus.lastSyncTime) { updateSyncStatus() }
|
||||
.onChange(of: gitSync.syncStatus.isCloned) { updateSyncStatus() }
|
||||
.onChange(of: gitSync.lastSyncError) { updateSyncStatus() }
|
||||
.onChange(of: gitSync.isSyncing) { updateSyncStatus() }
|
||||
.onChange(of: settings.syncConfigured) { updateSyncStatus() }
|
||||
}
|
||||
|
||||
private func updateSyncStatus() {
|
||||
if gitSync.lastSyncError != nil {
|
||||
syncText = "Sync Error"
|
||||
syncColor = .red
|
||||
syncState = .error; syncColor = .red
|
||||
} else if gitSync.isSyncing {
|
||||
syncText = "Syncing..."
|
||||
syncColor = .orange
|
||||
syncState = .syncing; syncColor = .orange
|
||||
} else if let lastSync = gitSync.syncStatus.lastSyncTime {
|
||||
syncText = "Last Sync: \(timeAgo(lastSync))"
|
||||
syncColor = .green
|
||||
syncState = .synced(lastSync); syncColor = .green
|
||||
} else if gitSync.syncStatus.isCloned {
|
||||
syncText = "Sync: Ready"
|
||||
syncColor = .secondary
|
||||
syncState = .ready; syncColor = .secondary
|
||||
} else if settings.syncConfigured {
|
||||
syncText = "Sync: Not Initialized"
|
||||
syncColor = .orange
|
||||
syncState = .notInitialized; syncColor = .orange
|
||||
} else {
|
||||
syncText = "Sync: Off"
|
||||
syncColor = .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let seconds = Int(Date().timeIntervalSince(date))
|
||||
if seconds < 60 {
|
||||
return "just now"
|
||||
} else if seconds < 3600 {
|
||||
let minutes = seconds / 60
|
||||
return "\(minutes)m ago"
|
||||
} else if seconds < 86400 {
|
||||
let hours = seconds / 3600
|
||||
return "\(hours)h ago"
|
||||
} else {
|
||||
let days = seconds / 86400
|
||||
return "\(days)d ago"
|
||||
syncState = .off; syncColor = .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ struct StatItem: View {
|
||||
|
||||
struct StatusPill: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let label: LocalizedStringKey
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
@@ -210,9 +210,31 @@ struct StatusPill: View {
|
||||
|
||||
struct SyncStatusPill: View {
|
||||
private let gitSync = GitSyncService.shared
|
||||
|
||||
private enum SyncPillState { case notConfigured, syncing, error(String), synced(Date?) }
|
||||
@State private var syncState: SyncPillState = .notConfigured
|
||||
@State private var syncColor: Color = .secondary
|
||||
@State private var syncLabel: String = "Sync"
|
||||
@State private var tooltipText: String = ""
|
||||
|
||||
private var syncLabel: LocalizedStringKey {
|
||||
switch syncState {
|
||||
case .notConfigured: return "Sync"
|
||||
case .syncing: return "Syncing"
|
||||
case .error: return "Error"
|
||||
case .synced: return "Synced"
|
||||
}
|
||||
}
|
||||
|
||||
private var tooltipText: LocalizedStringKey {
|
||||
switch syncState {
|
||||
case .notConfigured: return "Sync not configured"
|
||||
case .syncing: return "Syncing..."
|
||||
case .error(let msg): return "Sync failed: \(msg)"
|
||||
case .synced(let date):
|
||||
guard let date else { return "Synced" }
|
||||
let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now)
|
||||
return "Last synced: \(rel)"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
@@ -227,58 +249,21 @@ struct SyncStatusPill: View {
|
||||
.padding(.vertical, 2)
|
||||
.background(syncColor.opacity(0.1), in: Capsule())
|
||||
.help(tooltipText)
|
||||
.onAppear {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: gitSync.syncStatus) {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: gitSync.isSyncing) {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: gitSync.lastSyncError) {
|
||||
updateState()
|
||||
}
|
||||
.onAppear { updateState() }
|
||||
.onChange(of: gitSync.syncStatus) { updateState() }
|
||||
.onChange(of: gitSync.isSyncing) { updateState() }
|
||||
.onChange(of: gitSync.lastSyncError) { updateState() }
|
||||
}
|
||||
|
||||
private func updateState() {
|
||||
// Determine sync state
|
||||
if let error = gitSync.lastSyncError {
|
||||
syncColor = .red
|
||||
syncLabel = "Error"
|
||||
tooltipText = "Sync failed: \(error)"
|
||||
syncState = .error(error); syncColor = .red
|
||||
} else if gitSync.isSyncing {
|
||||
syncColor = .orange
|
||||
syncLabel = "Syncing"
|
||||
tooltipText = "Syncing..."
|
||||
syncState = .syncing; syncColor = .orange
|
||||
} else if gitSync.syncStatus.isCloned {
|
||||
syncColor = .green
|
||||
syncLabel = "Synced"
|
||||
if let lastSync = gitSync.syncStatus.lastSyncTime {
|
||||
tooltipText = "Last synced: \(timeAgo(lastSync))"
|
||||
} else {
|
||||
tooltipText = "Synced"
|
||||
}
|
||||
syncState = .synced(gitSync.syncStatus.lastSyncTime); syncColor = .green
|
||||
} else {
|
||||
syncColor = .secondary
|
||||
syncLabel = "Sync"
|
||||
tooltipText = "Sync not configured"
|
||||
}
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let seconds = Int(Date().timeIntervalSince(date))
|
||||
if seconds < 60 {
|
||||
return "just now"
|
||||
} else if seconds < 3600 {
|
||||
let minutes = seconds / 60
|
||||
return "\(minutes)m ago"
|
||||
} else if seconds < 86400 {
|
||||
let hours = seconds / 3600
|
||||
return "\(hours)h ago"
|
||||
} else {
|
||||
let days = seconds / 86400
|
||||
return "\(days)d ago"
|
||||
syncState = .notConfigured; syncColor = .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ struct CommandSuggestionsView: View {
|
||||
let selectedIndex: Int
|
||||
let onSelect: (String) -> Void
|
||||
|
||||
static let builtInCommands: [(command: String, description: String)] = [
|
||||
static let builtInCommands: [(command: String, description: LocalizedStringKey)] = [
|
||||
("/help", "Show help and available commands"),
|
||||
("/history", "View command history"),
|
||||
("/model", "Select AI model"),
|
||||
@@ -302,19 +302,19 @@ struct CommandSuggestionsView: View {
|
||||
("/mcp write off", "Disable MCP write permissions"),
|
||||
]
|
||||
|
||||
static func allCommands() -> [(command: String, description: String)] {
|
||||
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
|
||||
let shortcuts = SettingsService.shared.userShortcuts.map { s in
|
||||
(s.command, "⚡ \(s.description)")
|
||||
(s.command, LocalizedStringKey("⚡ \(s.description)"))
|
||||
}
|
||||
return builtInCommands + shortcuts
|
||||
}
|
||||
|
||||
static func filteredCommands(for searchText: String) -> [(command: String, description: String)] {
|
||||
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
|
||||
let search = searchText.lowercased()
|
||||
return allCommands().filter { $0.command.contains(search) || search == "/" }
|
||||
}
|
||||
|
||||
private var suggestions: [(command: String, description: String)] {
|
||||
private var suggestions: [(command: String, description: LocalizedStringKey)] {
|
||||
Self.filteredCommands(for: searchText)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ struct MessageRow: View {
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
@State private var isExpanded = false
|
||||
@State private var isThinkingExpanded = true // auto-expand while streaming, collapse after
|
||||
|
||||
#if os(macOS)
|
||||
@State private var isHovering = false
|
||||
@@ -107,6 +108,27 @@ struct MessageRow: View {
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
|
||||
// Thinking / reasoning block (collapsible)
|
||||
if let thinking = message.thinkingContent, !thinking.isEmpty {
|
||||
thinkingBlock(thinking)
|
||||
.onChange(of: message.content) { _, newContent in
|
||||
// Auto-collapse when response content starts arriving
|
||||
if !newContent.isEmpty && isThinkingExpanded && !message.isStreaming {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isThinkingExpanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: message.isStreaming) { _, streaming in
|
||||
// Collapse when streaming finishes
|
||||
if !streaming && !message.content.isEmpty {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isThinkingExpanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
if !message.content.isEmpty {
|
||||
messageContent
|
||||
@@ -186,6 +208,64 @@ struct MessageRow: View {
|
||||
// Close standardMessageLayout - the above closing braces close it
|
||||
// The body: some View now handles the split between compact and standard
|
||||
|
||||
// MARK: - Thinking Block
|
||||
|
||||
@ViewBuilder
|
||||
private func thinkingBlock(_ thinking: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header button
|
||||
Button(action: {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { isThinkingExpanded.toggle() }
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
if message.isStreaming && message.content.isEmpty {
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
.frame(width: 12, height: 12)
|
||||
Text("Thinking…")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Reasoning")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: isThinkingExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(.secondary.opacity(0.5))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isThinkingExpanded {
|
||||
Divider()
|
||||
.padding(.horizontal, 6)
|
||||
ScrollView {
|
||||
Text(thinking)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
}
|
||||
.frame(maxHeight: 220)
|
||||
}
|
||||
}
|
||||
.background(Color.secondary.opacity(0.07))
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(Color.secondary.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Compact System Message
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -38,7 +38,7 @@ struct AgentSkillsView: View {
|
||||
@Bindable private var settings = SettingsService.shared
|
||||
|
||||
@State private var editContext: SkillEditContext? = nil
|
||||
@State private var statusMessage: String? = nil
|
||||
@State private var statusMessage: LocalizedStringKey? = nil
|
||||
|
||||
private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count }
|
||||
|
||||
@@ -166,7 +166,7 @@ struct AgentSkillsView: View {
|
||||
if importMarkdown(url) { imported += 1 }
|
||||
}
|
||||
}
|
||||
if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") }
|
||||
if imported > 0 { show("^[\(imported) skill](inflect: true) imported") }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -256,7 +256,7 @@ struct AgentSkillsView: View {
|
||||
}
|
||||
exported += 1
|
||||
}
|
||||
show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads")
|
||||
show("^[\(exported) skill](inflect: true) exported to Downloads")
|
||||
}
|
||||
|
||||
private func exportOne(_ skill: AgentSkill) {
|
||||
@@ -335,7 +335,7 @@ struct AgentSkillsView: View {
|
||||
return ""
|
||||
}
|
||||
|
||||
private func show(_ text: String) {
|
||||
private func show(_ text: LocalizedStringKey) {
|
||||
statusMessage = text
|
||||
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
|
||||
}
|
||||
@@ -380,7 +380,7 @@ private struct AgentSkillRow: View {
|
||||
|
||||
// File count badge
|
||||
if fileCount > 0 {
|
||||
Label("\(fileCount) file\(fileCount == 1 ? "" : "s")", systemImage: "doc")
|
||||
Label("^[\(fileCount) file](inflect: true)", systemImage: "doc")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(.blue.opacity(0.1), in: Capsule())
|
||||
@@ -411,7 +411,7 @@ struct AgentSkillsTabContent: View {
|
||||
@Bindable private var settings = SettingsService.shared
|
||||
|
||||
@State private var editContext: SkillEditContext? = nil
|
||||
@State private var statusMessage: String? = nil
|
||||
@State private var statusMessage: LocalizedStringKey? = nil
|
||||
|
||||
private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count }
|
||||
|
||||
@@ -431,7 +431,7 @@ struct AgentSkillsTabContent: View {
|
||||
.background(.purple.opacity(0.07), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
if activeCount > 0 {
|
||||
Label("\(activeCount) skill\(activeCount == 1 ? "" : "s") active — appended to every system prompt", systemImage: "checkmark.circle.fill")
|
||||
Label("^[\(activeCount) skill](inflect: true) active — appended to every system prompt", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption).foregroundStyle(.green)
|
||||
}
|
||||
|
||||
@@ -516,7 +516,7 @@ struct AgentSkillsTabContent: View {
|
||||
if importMarkdown(url) { imported += 1 }
|
||||
}
|
||||
}
|
||||
if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") }
|
||||
if imported > 0 { show("^[\(imported) skill](inflect: true) imported") }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -602,7 +602,7 @@ struct AgentSkillsTabContent: View {
|
||||
}
|
||||
exported += 1
|
||||
}
|
||||
show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads")
|
||||
show("^[\(exported) skill](inflect: true) exported to Downloads")
|
||||
}
|
||||
|
||||
private func exportOne(_ skill: AgentSkill) {
|
||||
@@ -671,7 +671,7 @@ struct AgentSkillsTabContent: View {
|
||||
return ""
|
||||
}
|
||||
|
||||
private func show(_ text: String) {
|
||||
private func show(_ text: LocalizedStringKey) {
|
||||
statusMessage = text
|
||||
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ struct CreditsView: View {
|
||||
}
|
||||
|
||||
struct CreditRow: View {
|
||||
let label: String
|
||||
let label: LocalizedStringKey
|
||||
let value: String
|
||||
var highlight: Bool = false
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ struct EmailLogView: View {
|
||||
|
||||
private var bottomActions: some View {
|
||||
HStack {
|
||||
Text("\(logs.count) \(logs.count == 1 ? "entry" : "entries")")
|
||||
Text("^[\(logs.count) entry](inflect: true)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -327,7 +327,7 @@ struct EmailLogRow: View {
|
||||
// MARK: - Stat Item
|
||||
|
||||
struct EmailStatItem: View {
|
||||
let title: String
|
||||
let title: LocalizedStringKey
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ struct CommandDetail: Identifiable {
|
||||
|
||||
struct CommandCategory: Identifiable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let name: LocalizedStringKey
|
||||
let icon: String
|
||||
let commands: [CommandDetail]
|
||||
}
|
||||
@@ -358,7 +358,7 @@ struct HelpView: View {
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
|
||||
Spacer()
|
||||
Text(shortcut.description)
|
||||
Text(LocalizedStringKey(shortcut.description))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -463,7 +463,7 @@ private struct CommandRow: View {
|
||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(command.brief)
|
||||
Text(LocalizedStringKey(command.brief))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -493,14 +493,14 @@ private struct CommandRow: View {
|
||||
// Expanded detail
|
||||
if isExpanded {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(command.detail)
|
||||
Text(LocalizedStringKey(command.detail))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if !command.examples.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(command.examples.count == 1 ? "Example" : "Examples")
|
||||
Text(command.examples.count == 1 ? "Example" as LocalizedStringKey : "Examples")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fontWeight(.medium)
|
||||
|
||||
@@ -132,6 +132,7 @@ struct ModelInfoView: View {
|
||||
capabilityBadge(icon: "wrench.fill", label: "Tools", active: model.capabilities.tools)
|
||||
capabilityBadge(icon: "globe", label: "Online", active: model.capabilities.online)
|
||||
capabilityBadge(icon: "photo.fill", label: "Image Gen", active: model.capabilities.imageGeneration)
|
||||
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
|
||||
}
|
||||
|
||||
// Architecture (if available)
|
||||
@@ -172,14 +173,14 @@ struct ModelInfoView: View {
|
||||
|
||||
// MARK: - Layout Helpers
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
private func infoRow(_ label: String, _ value: String) -> some View {
|
||||
private func infoRow(_ label: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.body)
|
||||
@@ -193,7 +194,7 @@ struct ModelInfoView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func costExample(label: String, inputTokens: Int) -> some View {
|
||||
private func costExample(label: LocalizedStringKey, inputTokens: Int) -> some View {
|
||||
let cost = (Double(inputTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(inputTokens) * model.pricing.completion / 1_000_000)
|
||||
VStack(spacing: 2) {
|
||||
@@ -211,7 +212,7 @@ struct ModelInfoView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func capabilityBadge(icon: String, label: String, active: Bool) -> some View {
|
||||
private func capabilityBadge(icon: String, label: LocalizedStringKey, active: Bool) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
|
||||
@@ -36,20 +36,37 @@ struct ModelSelectorView: View {
|
||||
@State private var filterTools = false
|
||||
@State private var filterOnline = false
|
||||
@State private var filterImageGen = false
|
||||
@State private var filterThinking = false
|
||||
@State private var keyboardIndex: Int = -1
|
||||
@State private var sortOrder: ModelSortOrder = .default
|
||||
@State private var selectedInfoModel: ModelInfo? = nil
|
||||
|
||||
private var filteredModels: [ModelInfo] {
|
||||
models.filter { model in
|
||||
let q = searchText.lowercased()
|
||||
let filtered = models.filter { model in
|
||||
let matchesSearch = searchText.isEmpty ||
|
||||
model.name.lowercased().contains(searchText.lowercased()) ||
|
||||
model.id.lowercased().contains(searchText.lowercased())
|
||||
model.name.lowercased().contains(q) ||
|
||||
model.id.lowercased().contains(q) ||
|
||||
model.description?.lowercased().contains(q) == true
|
||||
|
||||
let matchesVision = !filterVision || model.capabilities.vision
|
||||
let matchesTools = !filterTools || model.capabilities.tools
|
||||
let matchesOnline = !filterOnline || model.capabilities.online
|
||||
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
||||
let matchesThinking = !filterThinking || model.capabilities.thinking
|
||||
|
||||
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen
|
||||
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking
|
||||
}
|
||||
|
||||
switch sortOrder {
|
||||
case .default:
|
||||
return filtered
|
||||
case .priceLowHigh:
|
||||
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
|
||||
case .priceHighLow:
|
||||
return filtered.sorted { $0.pricing.prompt > $1.pricing.prompt }
|
||||
case .contextHighLow:
|
||||
return filtered.sorted { $0.contextLength > $1.contextLength }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,16 +78,47 @@ struct ModelSelectorView: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding()
|
||||
.onChange(of: searchText) {
|
||||
// Reset keyboard index when search changes
|
||||
keyboardIndex = -1
|
||||
}
|
||||
|
||||
// Filters
|
||||
// Filters + Sort
|
||||
HStack(spacing: 12) {
|
||||
FilterToggle(isOn: $filterVision, icon: "\u{1F441}\u{FE0F}", label: "Vision")
|
||||
FilterToggle(isOn: $filterTools, icon: "\u{1F527}", label: "Tools")
|
||||
FilterToggle(isOn: $filterOnline, icon: "\u{1F310}", label: "Online")
|
||||
FilterToggle(isOn: $filterImageGen, icon: "\u{1F3A8}", label: "Image Gen")
|
||||
FilterToggle(isOn: $filterThinking, icon: "\u{1F9E0}", label: "Thinking")
|
||||
|
||||
Spacer()
|
||||
|
||||
// Sort menu
|
||||
Menu {
|
||||
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
|
||||
Button {
|
||||
sortOrder = order
|
||||
keyboardIndex = -1
|
||||
} label: {
|
||||
if sortOrder == order {
|
||||
Label(order.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(order.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
Text("Sort")
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(sortOrder != .default ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
|
||||
.foregroundColor(sortOrder != .default ? .blue : .secondary)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 12)
|
||||
@@ -91,13 +139,11 @@ struct ModelSelectorView: View {
|
||||
ModelRowView(
|
||||
model: model,
|
||||
isSelected: model.id == selectedModel?.id,
|
||||
isKeyboardHighlighted: index == keyboardIndex
|
||||
isKeyboardHighlighted: index == keyboardIndex,
|
||||
onSelect: { onSelect(model) },
|
||||
onInfo: { selectedInfoModel = model }
|
||||
)
|
||||
.id(model.id)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onSelect(model)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: keyboardIndex) { _, newIndex in
|
||||
@@ -143,20 +189,42 @@ struct ModelSelectorView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Initialize keyboard index to current selection
|
||||
if let selected = selectedModel,
|
||||
let index = filteredModels.firstIndex(where: { $0.id == selected.id }) {
|
||||
keyboardIndex = index
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedInfoModel) { model in
|
||||
ModelInfoView(model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sort Order
|
||||
|
||||
enum ModelSortOrder: String, CaseIterable {
|
||||
case `default`
|
||||
case priceLowHigh
|
||||
case priceHighLow
|
||||
case contextHighLow
|
||||
|
||||
var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .default: return "Default"
|
||||
case .priceLowHigh: return "Price: Low to High"
|
||||
case .priceHighLow: return "Price: High to Low"
|
||||
case .contextHighLow: return "Context: High to Low"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter Toggle
|
||||
|
||||
struct FilterToggle: View {
|
||||
@Binding var isOn: Bool
|
||||
let icon: String
|
||||
let label: String
|
||||
let label: LocalizedStringKey
|
||||
|
||||
var body: some View {
|
||||
Button(action: { isOn.toggle() }) {
|
||||
@@ -175,51 +243,74 @@ struct FilterToggle: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model Row
|
||||
|
||||
struct ModelRowView: View {
|
||||
let model: ModelInfo
|
||||
let isSelected: Bool
|
||||
var isKeyboardHighlighted: Bool = false
|
||||
let onSelect: () -> Void
|
||||
let onInfo: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(model.name)
|
||||
.font(.headline)
|
||||
.foregroundColor(isSelected ? .blue : .primary)
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
// Selectable main content
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(model.name)
|
||||
.font(.headline)
|
||||
.foregroundColor(isSelected ? .blue : .primary)
|
||||
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
// Capabilities
|
||||
Text(model.id)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let description = model.description {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Label(model.contextLengthDisplay, systemImage: "text.alignleft")
|
||||
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onSelect() }
|
||||
|
||||
// Right side: capabilities + info button
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
// Capability icons
|
||||
HStack(spacing: 4) {
|
||||
if model.capabilities.vision { Text("\u{1F441}\u{FE0F}").font(.caption) }
|
||||
if model.capabilities.tools { Text("\u{1F527}").font(.caption) }
|
||||
if model.capabilities.online { Text("\u{1F310}").font(.caption) }
|
||||
if model.capabilities.imageGeneration { Text("\u{1F3A8}").font(.caption) }
|
||||
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
|
||||
}
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
// Info button
|
||||
Button(action: onInfo) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Show model info")
|
||||
}
|
||||
|
||||
Text(model.id)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let description = model.description {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Label(model.contextLengthDisplay, systemImage: "text.alignleft")
|
||||
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, isKeyboardHighlighted ? 4 : 0)
|
||||
|
||||
@@ -142,7 +142,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
tabButton(7, icon: "brain", label: "Skills")
|
||||
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
|
||||
tabButton(5, icon: "envelope", label: "Email")
|
||||
tabButton(8, icon: "doc.text", label: "Paperless")
|
||||
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
|
||||
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -283,10 +283,42 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
rowDivider()
|
||||
row("MCP (File Access)") {
|
||||
Toggle("", isOn: $settingsService.mcpEnabled)
|
||||
row("Reasoning (Thinking)") {
|
||||
Toggle("", isOn: $settingsService.reasoningEnabled)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
if settingsService.reasoningEnabled {
|
||||
rowDivider()
|
||||
row("Reasoning Effort") {
|
||||
Picker("", selection: $settingsService.reasoningEffort) {
|
||||
Text("High (~80%)").tag("high")
|
||||
Text("Medium (~50%)").tag("medium")
|
||||
Text("Low (~20%)").tag("low")
|
||||
Text("Minimal (~10%)").tag("minimal")
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(reasoningEffortDescription)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 4)
|
||||
rowDivider()
|
||||
row("Hide Reasoning in Response") {
|
||||
Toggle("", isOn: $settingsService.reasoningExclude)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Model thinks internally but reasoning is not shown in chat")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2050,13 +2082,25 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
|
||||
// MARK: - Tab Navigation
|
||||
|
||||
private func tabButton(_ tag: Int, icon: String, label: String) -> some View {
|
||||
private func tabButton(_ tag: Int, icon: String, label: LocalizedStringKey, beta: Bool = false) -> some View {
|
||||
Button(action: { selectedTab = tag }) {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 22))
|
||||
.frame(height: 28)
|
||||
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 22))
|
||||
.frame(height: 28)
|
||||
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||
if beta {
|
||||
Text("β")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 3)
|
||||
.padding(.vertical, 1)
|
||||
.background(Color.orange)
|
||||
.clipShape(Capsule())
|
||||
.offset(x: 6, y: -2)
|
||||
}
|
||||
}
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||
@@ -2070,7 +2114,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func tabTitle(_ tag: Int) -> String {
|
||||
private func tabTitle(_ tag: Int) -> LocalizedStringKey {
|
||||
switch tag {
|
||||
case 0: return "General"
|
||||
case 1: return "MCP"
|
||||
@@ -2086,13 +2130,23 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reasoning Helpers
|
||||
|
||||
private var reasoningEffortDescription: LocalizedStringKey {
|
||||
switch settingsService.reasoningEffort {
|
||||
case "high": return "Uses ~80% of max tokens for reasoning — best for hard problems"
|
||||
case "medium": return "Uses ~50% of max tokens for reasoning — balanced default"
|
||||
case "low": return "Uses ~20% of max tokens for reasoning — faster, cheaper"
|
||||
case "minimal": return "Uses ~10% of max tokens for reasoning — lightest thinking"
|
||||
default: return "Uses ~50% of max tokens for reasoning — balanced default"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Helpers
|
||||
|
||||
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
private func row<Content: View>(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
if !label.isEmpty {
|
||||
Text(label).font(.system(size: 14))
|
||||
}
|
||||
Text(label).font(.system(size: 14))
|
||||
Spacer()
|
||||
content()
|
||||
}
|
||||
@@ -2100,7 +2154,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -2205,7 +2259,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
return .green
|
||||
}
|
||||
|
||||
private var syncStatusText: String {
|
||||
private var syncStatusText: LocalizedStringKey {
|
||||
guard settingsService.syncEnabled else { return "Disabled" }
|
||||
guard settingsService.syncConfigured else { return "Not configured" }
|
||||
guard gitSync.syncStatus.isCloned else { return "Not cloned" }
|
||||
@@ -2318,19 +2372,9 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let seconds = Int(Date().timeIntervalSince(date))
|
||||
if seconds < 60 {
|
||||
return "just now"
|
||||
} else if seconds < 3600 {
|
||||
let minutes = seconds / 60
|
||||
return "\(minutes) minute\(minutes == 1 ? "" : "s") ago"
|
||||
} else if seconds < 86400 {
|
||||
let hours = seconds / 3600
|
||||
return "\(hours) hour\(hours == 1 ? "" : "s") ago"
|
||||
} else {
|
||||
let days = seconds / 86400
|
||||
return "\(days) day\(days == 1 ? "" : "s") ago"
|
||||
}
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: date, relativeTo: .now)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ struct StatsView: View {
|
||||
}
|
||||
|
||||
struct StatRow: View {
|
||||
let label: String
|
||||
let label: LocalizedStringKey
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
@@ -137,7 +137,7 @@ struct StatRow: View {
|
||||
|
||||
struct CapabilityBadge: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let label: LocalizedStringKey
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
|
||||
42
oAI/oAI-InfoPlist.xcstrings
Normal file
42
oAI/oAI-InfoPlist.xcstrings
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"CFBundleName" : {
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"da" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "oAI"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "oAI"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "oAI"
|
||||
}
|
||||
},
|
||||
"nb" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "oAI"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "oAI"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
}
|
||||
Reference in New Issue
Block a user