Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f9dc05774 | |||
| 3f9b30bfa1 | |||
| 375b8fb345 | |||
| 305abfa85d | |||
| c5c2667553 | |||
| f375b1172b |
16
README.md
16
README.md
@@ -53,6 +53,15 @@ Seamless conversation backup and sync across devices:
|
||||
- **Shortcuts** - Personal slash commands that expand to prompt templates; optional `{{input}}` placeholder for inline input
|
||||
- **Agent Skills (SKILL.md)** - Markdown instruction files injected into the system prompt; compatible with skill0.io, skillsmp.com, and other SKILL.md marketplaces; import as `.md` or `.zip` bundle with attached data files
|
||||
|
||||
### 📚 Anytype Integration
|
||||
Connect oAI to your local [Anytype](https://anytype.io) knowledge base:
|
||||
- **Search** — find objects by keyword across all spaces or within a specific one
|
||||
- **Read** — open any object and read its full markdown content
|
||||
- **Append** — add content to the end of an existing object without touching existing text or internal links (preferred over full update)
|
||||
- **Create** — make new notes, tasks, or pages
|
||||
- **Checkbox tools** — surgically toggle to-do checkboxes or set task done/undone via native relation
|
||||
- All data stays on your machine (local API, no cloud)
|
||||
|
||||
### 🖥️ Power-User Features
|
||||
- **Bash Execution** - AI can run shell commands via `/bin/zsh` (opt-in, with per-command approval prompt)
|
||||
- **iCloud Backup** - One-click settings backup to iCloud Drive; restore on any Mac; API keys excluded for security
|
||||
@@ -76,8 +85,9 @@ Automated email responses powered by AI:
|
||||
- Footer stats display (messages, tokens, cost, sync status)
|
||||
- Header status indicators (MCP, Online mode, Git sync)
|
||||
- Responsive message layout with copy buttons
|
||||
- **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠), sort by price or context window, search by name or description, per-row ⓘ info button
|
||||
- **Localization** - UI fully translated into Norwegian Bokmål, Swedish, Danish, and German; follows macOS language preference automatically
|
||||
- **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠), sort by price or context window, search by name or description, per-row ⓘ info button; ★ favourite any model — favourites float to the top and can be filtered in one click
|
||||
- **Default Model** - Set a fixed startup model in Settings → General; switching models during a session does not overwrite it
|
||||
- **Localization** - UI ~~fully translated~~ being translated into Norwegian Bokmål, Swedish, Danish, and German; follows macOS language preference automatically
|
||||
|
||||

|
||||
|
||||
@@ -306,6 +316,8 @@ AI-powered email auto-responder:
|
||||
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German)
|
||||
- [x] iCloud Backup (settings export/restore)
|
||||
- [x] Bash execution with per-command approval
|
||||
- [x] Anytype integration (read, append, create, checkbox tools)
|
||||
- [x] Model favourites (starred models, filter, float to top)
|
||||
- [ ] SOUL.md / USER.md — living identity documents injected into system prompt
|
||||
- [ ] Parallel research agents (read-only, concurrent)
|
||||
- [ ] Local embeddings (sentence-transformers, $0 cost)
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
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.6;
|
||||
MARKETING_VERSION = 2.3.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -327,7 +327,7 @@
|
||||
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.6;
|
||||
MARKETING_VERSION = 2.3.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -160,6 +160,18 @@ struct OpenRouterChatResponse: Codable {
|
||||
let content: String?
|
||||
let toolCalls: [APIToolCall]?
|
||||
let images: [ImageOutput]?
|
||||
// Images extracted from content[] blocks (e.g. GPT-5 Image response format)
|
||||
let contentBlockImages: [ImageOutput]
|
||||
|
||||
private struct ContentBlock: Codable {
|
||||
let type: String
|
||||
let text: String?
|
||||
let imageUrl: ImageOutput.ImageURL?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type, text
|
||||
case imageUrl = "image_url"
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role
|
||||
@@ -167,6 +179,27 @@ struct OpenRouterChatResponse: Codable {
|
||||
case toolCalls = "tool_calls"
|
||||
case images
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
role = try c.decode(String.self, forKey: .role)
|
||||
toolCalls = try c.decodeIfPresent([APIToolCall].self, forKey: .toolCalls)
|
||||
images = try c.decodeIfPresent([ImageOutput].self, forKey: .images)
|
||||
// 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 ImageOutput(imageUrl: url)
|
||||
}
|
||||
} else {
|
||||
content = nil
|
||||
contentBlockImages = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
|
||||
@@ -160,8 +160,17 @@ class OpenRouterProvider: AIProvider {
|
||||
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
// Debug: log raw response for image gen models
|
||||
if request.imageGeneration, let rawStr = String(data: data, encoding: .utf8) {
|
||||
Log.api.debug("Image gen raw response (first 3000 chars): \(rawStr.prefix(3000))")
|
||||
}
|
||||
|
||||
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
|
||||
return try convertToChatResponse(apiResponse)
|
||||
let chatResponse = try convertToChatResponse(apiResponse)
|
||||
if request.imageGeneration {
|
||||
Log.api.debug("Image gen decoded: content='\(chatResponse.content)', generatedImages=\(chatResponse.generatedImages?.count ?? 0)")
|
||||
}
|
||||
return chatResponse
|
||||
}
|
||||
|
||||
// MARK: - Chat with raw tool messages
|
||||
@@ -396,7 +405,10 @@ class OpenRouterProvider: AIProvider {
|
||||
ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments)
|
||||
}
|
||||
|
||||
let images = choice.message.images.flatMap { decodeImageOutputs($0) }
|
||||
let topLevelImages = choice.message.images.flatMap { decodeImageOutputs($0) } ?? []
|
||||
let blockImages = decodeImageOutputs(choice.message.contentBlockImages) ?? []
|
||||
let allImages = topLevelImages + blockImages
|
||||
let images: [Data]? = allImages.isEmpty ? nil : allImages
|
||||
|
||||
return ChatResponse(
|
||||
id: apiResponse.id,
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<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="#anytype">Anytype Integration</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>
|
||||
@@ -117,14 +118,23 @@
|
||||
<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>Default</strong> — provider order, with favourites floated to the top</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>Favourite Models</h3>
|
||||
<p>Click the <strong>★</strong> star next to any model name to mark it as a favourite. Favourites:</p>
|
||||
<ul>
|
||||
<li>Float to the top of the Default sort order</li>
|
||||
<li>Can be filtered to show only with the <strong>☆</strong> star button in the toolbar</li>
|
||||
<li>Are shown as a filled yellow star ★ in the model row, the Model Info sheet, and the header bar</li>
|
||||
<li>Are shared across all three places — toggling in any one updates all</li>
|
||||
</ul>
|
||||
|
||||
<h3>Model Information</h3>
|
||||
<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>
|
||||
<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. The sheet also has a ★ star button to toggle favourites. You can also type:</p>
|
||||
<code class="command">/info</code>
|
||||
<p class="note">Shows information about the currently selected model.</p>
|
||||
|
||||
@@ -132,7 +142,7 @@
|
||||
<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>
|
||||
<p>Set a model that oAI always opens with in <strong>Settings → General → Model Settings → Default Model</strong>. Click <strong>Choose…</strong> to pick from the full model list, or <strong>Clear</strong> to remove the default. Switching models during a chat session does <em>not</em> change your saved default — it only changes the current session.</p>
|
||||
</section>
|
||||
|
||||
<!-- Sending Messages -->
|
||||
@@ -1361,6 +1371,48 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
</section>
|
||||
|
||||
<!-- Keyboard Shortcuts -->
|
||||
<!-- Anytype Integration -->
|
||||
<section id="anytype">
|
||||
<h2>Anytype Integration</h2>
|
||||
<p>oAI can connect to your local <a href="https://anytype.io" target="_blank">Anytype</a> desktop app, giving the AI read and write access to your personal knowledge base. All data stays on your machine — the API is local-only.</p>
|
||||
|
||||
<h3>Requirements</h3>
|
||||
<ul>
|
||||
<li>Anytype desktop app installed and running</li>
|
||||
<li>An API key generated inside Anytype (Settings → Integrations)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Setup</h3>
|
||||
<ol>
|
||||
<li>Open oAI Settings → Anytype tab</li>
|
||||
<li>Enable the toggle</li>
|
||||
<li>Enter your API key (leave the URL as the default unless your setup is unusual)</li>
|
||||
<li>Click <strong>Test Connection</strong> — a success message will show how many spaces were found</li>
|
||||
</ol>
|
||||
|
||||
<h3>What the AI Can Do</h3>
|
||||
<ul>
|
||||
<li><strong>Search</strong> — find objects by keyword across all spaces or within a specific one</li>
|
||||
<li><strong>Read</strong> — open any object and read its full markdown content</li>
|
||||
<li><strong>Create</strong> — make new notes, tasks, or pages</li>
|
||||
<li><strong>Append</strong> — add content to the end of an existing object without touching the rest (recommended for edits)</li>
|
||||
<li><strong>Update</strong> — rewrite the full body of an object (use only when truly restructuring content)</li>
|
||||
<li><strong>Checkboxes</strong> — toggle individual to-do checkboxes by text match, or mark tasks done via their native relation</li>
|
||||
</ul>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Tip — Append vs Update:</strong> Use <em>append</em> whenever you want to add content to an existing note. It fetches the current body, adds your new content at the end, and saves — leaving all existing text, links, and internal Anytype references intact. <em>Update</em> replaces the entire body and can degrade rich Anytype internal links (anytype://...) to plain text.
|
||||
</div>
|
||||
|
||||
<h3>Example Prompts</h3>
|
||||
<ul>
|
||||
<li>"Search my Anytype for notes about Swift concurrency"</li>
|
||||
<li>"Create a new task called 'Review PR #42' in my Work space"</li>
|
||||
<li>"Add today's meeting summary to my Weekly Notes object"</li>
|
||||
<li>"Mark the 'Buy groceries' checkbox as done in my Shopping List"</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="keyboard-shortcuts">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<p>Work faster with these keyboard shortcuts.</p>
|
||||
@@ -1549,6 +1601,27 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<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>
|
||||
|
||||
<h3>Anytype Tab</h3>
|
||||
<p>Connect oAI to your local <a href="https://anytype.io" target="_blank">Anytype</a> desktop app so the AI can search, read, and add content to your knowledge base.</p>
|
||||
<ul>
|
||||
<li><strong>Enable Anytype</strong> — toggle to activate the integration</li>
|
||||
<li><strong>API URL</strong> — local Anytype API endpoint (default: <code>http://127.0.0.1:31009</code>)</li>
|
||||
<li><strong>API Key</strong> — generated in Anytype → Settings → Integrations</li>
|
||||
<li><strong>Test Connection</strong> — verify connectivity and list available spaces</li>
|
||||
</ul>
|
||||
<p>When enabled, the AI has access to these tools:</p>
|
||||
<ul>
|
||||
<li><code>anytype_search_global</code> / <code>anytype_search_space</code> — search across all or a specific space</li>
|
||||
<li><code>anytype_list_spaces</code> / <code>anytype_get_space_objects</code> — explore your spaces</li>
|
||||
<li><code>anytype_get_object</code> — read the full markdown body of any object</li>
|
||||
<li><code>anytype_create_object</code> — create a new note, task, or page</li>
|
||||
<li><code>anytype_append_to_object</code> — <strong>add content to an existing object without rewriting it</strong> (preferred for edits — preserves Anytype internal links)</li>
|
||||
<li><code>anytype_update_object</code> — replace the full body (use sparingly; prefer append)</li>
|
||||
<li><code>anytype_toggle_checkbox</code> — surgically check/uncheck a to-do item by text match</li>
|
||||
<li><code>anytype_set_done</code> — mark a task done/undone via its native relation</li>
|
||||
</ul>
|
||||
<p class="note"><strong>Note:</strong> The Anytype desktop app must be running for the integration to work. The API is local-only — no data leaves your machine.</p>
|
||||
</section>
|
||||
|
||||
<!-- System Prompts -->
|
||||
|
||||
@@ -105,13 +105,28 @@ class AnytypeMCPService {
|
||||
],
|
||||
required: ["space_id", "name"]
|
||||
),
|
||||
makeTool(
|
||||
name: "anytype_append_to_object",
|
||||
description: """
|
||||
Append new markdown content to the end of an existing Anytype object without touching the existing body. \
|
||||
This is the PREFERRED way to add content to existing notes, pages, or tasks — \
|
||||
it preserves all Anytype internal links (anytype://...) and mention blocks exactly. \
|
||||
Use this instead of anytype_update_object whenever you are adding information rather than rewriting.
|
||||
""",
|
||||
properties: [
|
||||
"space_id": prop("string", "The ID of the space containing the object"),
|
||||
"object_id": prop("string", "The ID of the object to append to"),
|
||||
"content": prop("string", "Markdown content to append at the end of the object body")
|
||||
],
|
||||
required: ["space_id", "object_id", "content"]
|
||||
),
|
||||
makeTool(
|
||||
name: "anytype_update_object",
|
||||
description: """
|
||||
Replace the full markdown body or rename an Anytype object. \
|
||||
IMPORTANT: For toggling a checkbox (to-do item), use anytype_toggle_checkbox instead — \
|
||||
it is safer and does not risk modifying other content. \
|
||||
Use anytype_update_object ONLY for large content changes (adding paragraphs, rewriting sections, etc.). \
|
||||
WARNING: This replaces the ENTIRE body — prefer anytype_append_to_object for adding content \
|
||||
to existing objects, as full replacement degrades rich Anytype internal links (anytype://...) to plain text. \
|
||||
Use this ONLY when you truly need to rewrite or restructure existing content. \
|
||||
CRITICAL RULES when using this tool: \
|
||||
1) Always call anytype_get_object first to get the current EXACT markdown. \
|
||||
2) Make ONLY the minimal requested change — nothing else. \
|
||||
@@ -214,6 +229,14 @@ class AnytypeMCPService {
|
||||
let type_ = args["type"] as? String ?? "note"
|
||||
return try await createObject(spaceId: spaceId, name: name, body: body, type: type_)
|
||||
|
||||
case "anytype_append_to_object":
|
||||
guard let spaceId = args["space_id"] as? String,
|
||||
let objectId = args["object_id"] as? String,
|
||||
let content = args["content"] as? String else {
|
||||
return ["error": "Missing required parameters: space_id, object_id, content"]
|
||||
}
|
||||
return try await appendToObject(spaceId: spaceId, objectId: objectId, content: content)
|
||||
|
||||
case "anytype_update_object":
|
||||
guard let spaceId = args["space_id"] as? String,
|
||||
let objectId = args["object_id"] as? String else {
|
||||
@@ -351,6 +374,29 @@ class AnytypeMCPService {
|
||||
return ["success": true, "message": "Object created"]
|
||||
}
|
||||
|
||||
private func appendToObject(spaceId: String, objectId: String, content: String) async throws -> [String: Any] {
|
||||
// Fetch current body
|
||||
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil)
|
||||
guard let object = result["object"] as? [String: Any] else {
|
||||
return ["error": "Object not found"]
|
||||
}
|
||||
|
||||
let existing: String
|
||||
if let md = object["markdown"] as? String { existing = md }
|
||||
else if let body = object["body"] as? String { existing = body }
|
||||
else { existing = "" }
|
||||
|
||||
let separator = existing.isEmpty ? "" : "\n\n"
|
||||
let newMarkdown = existing + separator + content
|
||||
|
||||
_ = try await request(
|
||||
endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)",
|
||||
method: "PATCH",
|
||||
body: ["markdown": newMarkdown]
|
||||
)
|
||||
return ["success": true, "message": "Content appended successfully"]
|
||||
}
|
||||
|
||||
private func updateObject(spaceId: String, objectId: String, name: String?, body: String?) async throws -> [String: Any] {
|
||||
var requestBody: [String: Any] = [:]
|
||||
if let name = name { requestBody["name"] = name }
|
||||
|
||||
@@ -430,6 +430,31 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Favorite Models
|
||||
|
||||
var favoriteModelIds: Set<String> {
|
||||
get {
|
||||
guard let json = cache["favoriteModelIds"],
|
||||
let data = json.data(using: .utf8),
|
||||
let ids = try? JSONDecoder().decode([String].self, from: data) else { return [] }
|
||||
return Set(ids)
|
||||
}
|
||||
set {
|
||||
let sorted = newValue.sorted()
|
||||
if let data = try? JSONEncoder().encode(sorted),
|
||||
let json = String(data: data, encoding: .utf8) {
|
||||
cache["favoriteModelIds"] = json
|
||||
DatabaseService.shared.setSetting(key: "favoriteModelIds", value: json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleFavoriteModel(_ id: String) {
|
||||
var favs = favoriteModelIds
|
||||
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
|
||||
favoriteModelIds = favs
|
||||
}
|
||||
|
||||
// MARK: - Anytype MCP Settings
|
||||
|
||||
var anytypeMcpEnabled: Bool {
|
||||
|
||||
@@ -417,11 +417,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let newProvider = inferProvider(from: model.id) ?? currentProvider
|
||||
selectedModel = model
|
||||
currentProvider = newProvider
|
||||
settings.defaultModel = model.id
|
||||
settings.defaultProvider = newProvider
|
||||
MCPService.shared.resetBashSessionApproval()
|
||||
}
|
||||
|
||||
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
|
||||
|
||||
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
||||
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
||||
if modelId.contains("/") { return .openrouter }
|
||||
@@ -1265,6 +1265,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
|
||||
let mcp = MCPService.shared
|
||||
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
|
||||
isGenerating = true
|
||||
streamingTask?.cancel()
|
||||
|
||||
@@ -1351,6 +1352,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit
|
||||
var finalContent = ""
|
||||
var finalImages: [Data] = []
|
||||
var didContinueAfterImages = false // Only inject temp-file continuation once
|
||||
var totalUsage: ChatResponse.Usage?
|
||||
var hitIterationLimit = false // Track if we exited due to hitting the limit
|
||||
|
||||
@@ -1379,9 +1382,32 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let toolCalls = structuredCalls.isEmpty ? textCalls : structuredCalls
|
||||
|
||||
guard !toolCalls.isEmpty else {
|
||||
// No tool calls — this is the final text response
|
||||
// Strip any unparseable tool call text from display
|
||||
// No tool calls — this is the final response
|
||||
finalContent = response.content
|
||||
if let images = response.generatedImages { finalImages = images }
|
||||
Log.ui.debug("Tools final response: content='\(response.content.prefix(80))', images=\(response.generatedImages?.count ?? 0)")
|
||||
|
||||
// If images were generated and tools are available, save to temp files
|
||||
// and continue the loop so the model can save them to the requested path.
|
||||
if !finalImages.isEmpty && !didContinueAfterImages && iteration < maxIterations - 1 {
|
||||
didContinueAfterImages = true
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let tempPaths: [String] = finalImages.enumerated().compactMap { i, imgData in
|
||||
let path = "/tmp/oai_generated_\(timestamp)_\(i).png"
|
||||
let ok = FileManager.default.createFile(atPath: path, contents: imgData)
|
||||
Log.ui.debug("Saved generated image to temp: \(path) ok=\(ok)")
|
||||
return ok ? path : nil
|
||||
}
|
||||
if !tempPaths.isEmpty {
|
||||
let pathList = tempPaths.joined(separator: ", ")
|
||||
let assistantContent = response.content.isEmpty ? "[Image generated]" : response.content
|
||||
apiMessages.append(["role": "assistant", "content": assistantContent])
|
||||
apiMessages.append(["role": "user", "content": "The image(s) have been generated and temporarily saved to: \(pathList). Please save them to the requested destination(s) using the available tools (bash or MCP write)."])
|
||||
finalImages = []
|
||||
finalContent = ""
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1491,7 +1517,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
attachments: nil,
|
||||
responseTime: responseTime,
|
||||
wasInterrupted: wasCancelled,
|
||||
modelId: modelId
|
||||
modelId: modelId,
|
||||
generatedImages: finalImages.isEmpty ? nil : finalImages
|
||||
)
|
||||
messages.append(assistantMessage)
|
||||
|
||||
@@ -1935,12 +1962,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
func detectGoodbyePhrase(in text: String) -> Bool {
|
||||
let lowercased = text.lowercased()
|
||||
let goodbyePhrases = [
|
||||
"bye", "goodbye", "bye bye",
|
||||
"thanks", "thank you", "thx", "ty",
|
||||
"bye", "goodbye", "bye bye", "good bye",
|
||||
"that's all", "thats all", "that'll be all",
|
||||
"done", "i'm done", "we're done",
|
||||
"i'm done", "we're done",
|
||||
"see you", "see ya", "catch you later",
|
||||
"have a good", "have a nice"
|
||||
"have a good day", "have a nice day"
|
||||
]
|
||||
|
||||
return goodbyePhrases.contains { phrase in
|
||||
|
||||
@@ -44,7 +44,7 @@ struct ContentView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 400)
|
||||
.frame(minWidth: 860, minHeight: 560)
|
||||
#if os(macOS)
|
||||
.onAppear {
|
||||
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
||||
@@ -120,24 +120,24 @@ struct ContentView: View {
|
||||
private var macOSToolbar: some ToolbarContent {
|
||||
let settings = SettingsService.shared
|
||||
let showLabels = settings.showToolbarLabels
|
||||
let scale = iconScale(for: settings.toolbarIconSize)
|
||||
let iconSize = settings.toolbarIconSize
|
||||
|
||||
ToolbarItemGroup(placement: .automatic) {
|
||||
// New conversation
|
||||
Button(action: { chatViewModel.newConversation() }) {
|
||||
ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, scale: scale)
|
||||
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, scale: scale)
|
||||
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, scale: scale)
|
||||
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.keyboardShortcut("h", modifiers: .command)
|
||||
.help("Command history (Cmd+H)")
|
||||
@@ -145,7 +145,7 @@ struct ContentView: View {
|
||||
Spacer()
|
||||
|
||||
Button(action: { chatViewModel.showModelSelector = true }) {
|
||||
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, scale: scale)
|
||||
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.keyboardShortcut("m", modifiers: .command)
|
||||
.help("Select AI model (Cmd+M)")
|
||||
@@ -155,32 +155,32 @@ struct ContentView: View {
|
||||
chatViewModel.modelInfoTarget = model
|
||||
}
|
||||
}) {
|
||||
ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, scale: scale)
|
||||
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, scale: scale)
|
||||
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, scale: scale)
|
||||
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, scale: scale)
|
||||
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, scale: scale)
|
||||
ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize)
|
||||
}
|
||||
.keyboardShortcut("/", modifiers: .command)
|
||||
.help("Help & commands (Cmd+/)")
|
||||
@@ -188,14 +188,6 @@ struct ContentView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Helper function to convert icon size to imageScale
|
||||
private func iconScale(for size: Double) -> Image.Scale {
|
||||
switch size {
|
||||
case ...18: return .small
|
||||
case 19...24: return .medium
|
||||
default: return .large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper view for toolbar labels
|
||||
@@ -203,17 +195,41 @@ struct ToolbarLabel: View {
|
||||
let title: LocalizedStringKey
|
||||
let systemImage: String
|
||||
let showLabels: Bool
|
||||
let scale: Image.Scale
|
||||
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 showLabels {
|
||||
Label(title, systemImage: systemImage)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.imageScale(scale)
|
||||
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 {
|
||||
Label(title, systemImage: systemImage)
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(scale)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ struct HeaderView: View {
|
||||
private let gitSync = GitSyncService.shared
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 20) {
|
||||
// Provider picker dropdown — only shows configured providers
|
||||
Menu {
|
||||
ForEach(registry.configuredProviders, id: \.self) { p in
|
||||
@@ -126,6 +126,17 @@ struct HeaderView: View {
|
||||
.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")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Status indicators
|
||||
|
||||
@@ -29,6 +29,7 @@ struct ModelInfoView: View {
|
||||
let model: ModelInfo
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Bindable private var settings = SettingsService.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -37,6 +38,15 @@ struct ModelInfoView: View {
|
||||
Text("Model Info")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Spacer()
|
||||
let isFav = settings.favoriteModelIds.contains(model.id)
|
||||
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
||||
Image(systemName: isFav ? "star.fill" : "star")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(isFav ? .yellow : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
||||
.padding(.trailing, 8)
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
|
||||
@@ -37,9 +37,11 @@ struct ModelSelectorView: View {
|
||||
@State private var filterOnline = false
|
||||
@State private var filterImageGen = false
|
||||
@State private var filterThinking = false
|
||||
@State private var filterFavorites = false
|
||||
@State private var keyboardIndex: Int = -1
|
||||
@State private var sortOrder: ModelSortOrder = .default
|
||||
@State private var selectedInfoModel: ModelInfo? = nil
|
||||
@Bindable private var settings = SettingsService.shared
|
||||
|
||||
private var filteredModels: [ModelInfo] {
|
||||
let q = searchText.lowercased()
|
||||
@@ -54,13 +56,20 @@ struct ModelSelectorView: View {
|
||||
let matchesOnline = !filterOnline || model.capabilities.online
|
||||
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
||||
let matchesThinking = !filterThinking || model.capabilities.thinking
|
||||
let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
|
||||
|
||||
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking
|
||||
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites
|
||||
}
|
||||
|
||||
let favIds = settings.favoriteModelIds
|
||||
switch sortOrder {
|
||||
case .default:
|
||||
return filtered
|
||||
return filtered.sorted { a, b in
|
||||
let aFav = favIds.contains(a.id)
|
||||
let bFav = favIds.contains(b.id)
|
||||
if aFav != bFav { return aFav }
|
||||
return false
|
||||
}
|
||||
case .priceLowHigh:
|
||||
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
|
||||
case .priceHighLow:
|
||||
@@ -91,6 +100,19 @@ struct ModelSelectorView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Favorites filter star
|
||||
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
||||
Image(systemName: filterFavorites ? "star.fill" : "star")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(filterFavorites ? Color.yellow.opacity(0.25) : Color.gray.opacity(0.1))
|
||||
.foregroundColor(filterFavorites ? .yellow : .secondary)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Show favorites only")
|
||||
|
||||
// Sort menu
|
||||
Menu {
|
||||
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
|
||||
@@ -140,7 +162,9 @@ struct ModelSelectorView: View {
|
||||
model: model,
|
||||
isSelected: model.id == selectedModel?.id,
|
||||
isKeyboardHighlighted: index == keyboardIndex,
|
||||
isFavorite: settings.favoriteModelIds.contains(model.id),
|
||||
onSelect: { onSelect(model) },
|
||||
onFavorite: { settings.toggleFavoriteModel(model.id) },
|
||||
onInfo: { selectedInfoModel = model }
|
||||
)
|
||||
.id(model.id)
|
||||
@@ -249,14 +273,25 @@ struct ModelRowView: View {
|
||||
let model: ModelInfo
|
||||
let isSelected: Bool
|
||||
var isKeyboardHighlighted: Bool = false
|
||||
var isFavorite: Bool = false
|
||||
let onSelect: () -> Void
|
||||
var onFavorite: (() -> Void)? = nil
|
||||
let onInfo: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
// Selectable main content
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
if let onFavorite {
|
||||
Button(action: onFavorite) {
|
||||
Image(systemName: isFavorite ? "star.fill" : "star")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(isFavorite ? .yellow : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(isFavorite ? "Remove from favorites" : "Add to favorites")
|
||||
}
|
||||
Text(model.name)
|
||||
.font(.headline)
|
||||
.foregroundColor(isSelected ? .blue : .primary)
|
||||
|
||||
@@ -58,6 +58,16 @@ struct SettingsView: View {
|
||||
@State private var syncTestResult: String?
|
||||
@State private var isSyncing = false
|
||||
|
||||
// Anytype state
|
||||
@State private var anytypeAPIKey = ""
|
||||
@State private var anytypeURL = ""
|
||||
@State private var showAnytypeKey = false
|
||||
@State private var isTestingAnytype = false
|
||||
@State private var anytypeTestResult: String?
|
||||
|
||||
// Default model picker state
|
||||
@State private var showDefaultModelPicker = false
|
||||
|
||||
// Paperless-NGX state
|
||||
@State private var paperlessURL = ""
|
||||
@State private var paperlessToken = ""
|
||||
@@ -136,14 +146,15 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
tabButton(2, icon: "paintbrush", label: "Appearance")
|
||||
tabButton(3, icon: "slider.horizontal.3", label: "Advanced")
|
||||
|
||||
Divider().frame(height: 44).padding(.horizontal, 8)
|
||||
Divider().frame(height: 44).padding(.horizontal, 4)
|
||||
|
||||
tabButton(6, icon: "command", label: "Shortcuts")
|
||||
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", beta: true)
|
||||
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
||||
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
|
||||
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
||||
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
@@ -173,6 +184,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
paperlessTab
|
||||
case 9:
|
||||
backupTab
|
||||
case 10:
|
||||
anytypeTab
|
||||
default:
|
||||
generalTab
|
||||
}
|
||||
@@ -183,6 +196,20 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
|
||||
}
|
||||
.frame(minWidth: 860, idealWidth: 940, minHeight: 620, idealHeight: 760)
|
||||
.sheet(isPresented: $showDefaultModelPicker) {
|
||||
ModelSelectorView(
|
||||
models: chatViewModel?.availableModels ?? [],
|
||||
selectedModel: chatViewModel?.availableModels.first(where: { $0.id == settingsService.defaultModel }),
|
||||
onSelect: { model in
|
||||
let provider = chatViewModel.flatMap { vm in
|
||||
vm.inferProviderPublic(from: model.id)
|
||||
} ?? settingsService.defaultProvider
|
||||
settingsService.defaultModel = model.id
|
||||
settingsService.defaultProvider = provider
|
||||
showDefaultModelPicker = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showEmailLog) {
|
||||
EmailLogView()
|
||||
}
|
||||
@@ -364,13 +391,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Model Settings")
|
||||
formSection {
|
||||
row("Default Model ID") {
|
||||
TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
|
||||
get: { settingsService.defaultModel ?? "" },
|
||||
set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 300)
|
||||
row("Default Model") {
|
||||
HStack(spacing: 8) {
|
||||
let modelName: String = {
|
||||
if let id = settingsService.defaultModel {
|
||||
return chatViewModel?.availableModels.first(where: { $0.id == id })?.name ?? id
|
||||
}
|
||||
return "Not set"
|
||||
}()
|
||||
Text(modelName)
|
||||
.foregroundStyle(settingsService.defaultModel == nil ? .secondary : .primary)
|
||||
.frame(maxWidth: 240, alignment: .leading)
|
||||
Button("Choose…") { showDefaultModelPicker = true }
|
||||
.buttonStyle(.borderless)
|
||||
if settingsService.defaultModel != nil {
|
||||
Button("Clear") {
|
||||
settingsService.defaultModel = nil
|
||||
settingsService.defaultProvider = .openrouter
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -749,7 +791,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
formSection {
|
||||
row("Icon Size") {
|
||||
HStack(spacing: 8) {
|
||||
Slider(value: $settingsService.toolbarIconSize, in: 16...32, step: 2)
|
||||
Slider(value: $settingsService.toolbarIconSize, in: 16...40, step: 2)
|
||||
.frame(maxWidth: 200)
|
||||
Text("\(Int(settingsService.toolbarIconSize)) pt")
|
||||
.font(.system(size: 13))
|
||||
@@ -1803,6 +1845,13 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
Toggle("", isOn: $settingsService.paperlessEnabled)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1902,6 +1951,117 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Anytype Tab
|
||||
|
||||
@ViewBuilder
|
||||
private var anytypeTab: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Anytype")
|
||||
formSection {
|
||||
row("Enable Anytype") {
|
||||
Toggle("", isOn: $settingsService.anytypeMcpEnabled)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if settingsService.anytypeMcpEnabled {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Connection")
|
||||
formSection {
|
||||
row("API URL") {
|
||||
TextField("http://127.0.0.1:31009", text: $anytypeURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 300)
|
||||
.onSubmit { settingsService.anytypeMcpURL = anytypeURL }
|
||||
.onChange(of: anytypeURL) { _, new in settingsService.anytypeMcpURL = new }
|
||||
}
|
||||
rowDivider()
|
||||
row("API Key") {
|
||||
HStack(spacing: 6) {
|
||||
if showAnytypeKey {
|
||||
TextField("", text: $anytypeAPIKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 240)
|
||||
.onSubmit { settingsService.anytypeMcpAPIKey = anytypeAPIKey.isEmpty ? nil : anytypeAPIKey }
|
||||
.onChange(of: anytypeAPIKey) { _, new in
|
||||
settingsService.anytypeMcpAPIKey = new.isEmpty ? nil : new
|
||||
}
|
||||
} else {
|
||||
SecureField("", text: $anytypeAPIKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 240)
|
||||
.onSubmit { settingsService.anytypeMcpAPIKey = anytypeAPIKey.isEmpty ? nil : anytypeAPIKey }
|
||||
.onChange(of: anytypeAPIKey) { _, new in
|
||||
settingsService.anytypeMcpAPIKey = new.isEmpty ? nil : new
|
||||
}
|
||||
}
|
||||
Button(showAnytypeKey ? "Hide" : "Show") {
|
||||
showAnytypeKey.toggle()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
}
|
||||
rowDivider()
|
||||
HStack(spacing: 12) {
|
||||
Button(action: { Task { await testAnytypeConnection() } }) {
|
||||
HStack {
|
||||
if isTestingAnytype {
|
||||
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle")
|
||||
}
|
||||
Text("Test Connection")
|
||||
}
|
||||
}
|
||||
.disabled(isTestingAnytype || !settingsService.anytypeMcpConfigured)
|
||||
if let result = anytypeTestResult {
|
||||
Text(result)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(result.hasPrefix("✓") ? .green : .red)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("How to get your API key:")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
Text("1. Open Anytype → Settings → Integrations")
|
||||
Text("2. Create a new API key")
|
||||
Text("3. Paste it above")
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
anytypeURL = settingsService.anytypeMcpURL
|
||||
anytypeAPIKey = settingsService.anytypeMcpAPIKey ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private func testAnytypeConnection() async {
|
||||
isTestingAnytype = true
|
||||
anytypeTestResult = nil
|
||||
let result = await AnytypeMCPService.shared.testConnection()
|
||||
await MainActor.run {
|
||||
switch result {
|
||||
case .success(let msg):
|
||||
anytypeTestResult = "✓ \(msg)"
|
||||
case .failure(let err):
|
||||
anytypeTestResult = "✗ \(err.localizedDescription)"
|
||||
}
|
||||
isTestingAnytype = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backup Tab
|
||||
|
||||
@ViewBuilder
|
||||
@@ -2092,22 +2252,22 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||
if beta {
|
||||
Text("β")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.font(.system(size: 9, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 3)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.orange)
|
||||
.clipShape(Capsule())
|
||||
.offset(x: 6, y: -2)
|
||||
.offset(x: 8, y: -3)
|
||||
}
|
||||
}
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||
}
|
||||
.frame(minWidth: 68)
|
||||
.frame(minWidth: 60)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.horizontal, 4)
|
||||
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user