New release v2.3.8 #4
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
|
- **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
|
- **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
|
### 🖥️ Power-User Features
|
||||||
- **Bash Execution** - AI can run shell commands via `/bin/zsh` (opt-in, with per-command approval prompt)
|
- **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
|
- **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)
|
- Footer stats display (messages, tokens, cost, sync status)
|
||||||
- Header status indicators (MCP, Online mode, Git sync)
|
- Header status indicators (MCP, Online mode, Git sync)
|
||||||
- Responsive message layout with copy buttons
|
- 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
|
- **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
|
||||||
- **Localization** - UI fully translated into Norwegian Bokmål, Swedish, Danish, and German; follows macOS language preference automatically
|
- **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] Localization (Norwegian Bokmål, Swedish, Danish, German)
|
||||||
- [x] iCloud Backup (settings export/restore)
|
- [x] iCloud Backup (settings export/restore)
|
||||||
- [x] Bash execution with per-command approval
|
- [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
|
- [ ] SOUL.md / USER.md — living identity documents injected into system prompt
|
||||||
- [ ] Parallel research agents (read-only, concurrent)
|
- [ ] Parallel research agents (read-only, concurrent)
|
||||||
- [ ] Local embeddings (sentence-transformers, $0 cost)
|
- [ ] Local embeddings (sentence-transformers, $0 cost)
|
||||||
|
|||||||
@@ -283,7 +283,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.3.7;
|
MARKETING_VERSION = 2.3.8;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -327,7 +327,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.3.7;
|
MARKETING_VERSION = 2.3.8;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<li><a href="#email-handler">Email Handler (AI Assistant)</a></li>
|
<li><a href="#email-handler">Email Handler (AI Assistant)</a></li>
|
||||||
<li><a href="#shortcuts">Shortcuts (Prompt Templates)</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="#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="#bash-execution">Bash Execution</a></li>
|
||||||
<li><a href="#icloud-backup">iCloud Backup</a></li>
|
<li><a href="#icloud-backup">iCloud Backup</a></li>
|
||||||
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
|
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
|
||||||
@@ -117,14 +118,23 @@
|
|||||||
<h3>Sorting</h3>
|
<h3>Sorting</h3>
|
||||||
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
|
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
|
||||||
<ul>
|
<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: Low to High</strong> — cheapest per million tokens first</li>
|
||||||
<li><strong>Price: High to Low</strong> — most capable/expensive 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>
|
<li><strong>Context: High to Low</strong> — largest context window first</li>
|
||||||
</ul>
|
</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>
|
<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>
|
<code class="command">/info</code>
|
||||||
<p class="note">Shows information about the currently selected model.</p>
|
<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>
|
<p>Use <kbd>↑</kbd> / <kbd>↓</kbd> to move through the list, <kbd>Return</kbd> to select the highlighted model.</p>
|
||||||
|
|
||||||
<h3>Default Model</h3>
|
<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>
|
</section>
|
||||||
|
|
||||||
<!-- Sending Messages -->
|
<!-- Sending Messages -->
|
||||||
@@ -1361,6 +1371,48 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Keyboard Shortcuts -->
|
<!-- 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">
|
<section id="keyboard-shortcuts">
|
||||||
<h2>Keyboard Shortcuts</h2>
|
<h2>Keyboard Shortcuts</h2>
|
||||||
<p>Work faster with these keyboard shortcuts.</p>
|
<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><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>
|
<li>API keys and credentials are excluded from backups and must be re-entered after restore</li>
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- System Prompts -->
|
<!-- System Prompts -->
|
||||||
|
|||||||
@@ -105,13 +105,28 @@ class AnytypeMCPService {
|
|||||||
],
|
],
|
||||||
required: ["space_id", "name"]
|
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(
|
makeTool(
|
||||||
name: "anytype_update_object",
|
name: "anytype_update_object",
|
||||||
description: """
|
description: """
|
||||||
Replace the full markdown body or rename an Anytype object. \
|
Replace the full markdown body or rename an Anytype object. \
|
||||||
IMPORTANT: For toggling a checkbox (to-do item), use anytype_toggle_checkbox instead — \
|
WARNING: This replaces the ENTIRE body — prefer anytype_append_to_object for adding content \
|
||||||
it is safer and does not risk modifying other content. \
|
to existing objects, as full replacement degrades rich Anytype internal links (anytype://...) to plain text. \
|
||||||
Use anytype_update_object ONLY for large content changes (adding paragraphs, rewriting sections, etc.). \
|
Use this ONLY when you truly need to rewrite or restructure existing content. \
|
||||||
CRITICAL RULES when using this tool: \
|
CRITICAL RULES when using this tool: \
|
||||||
1) Always call anytype_get_object first to get the current EXACT markdown. \
|
1) Always call anytype_get_object first to get the current EXACT markdown. \
|
||||||
2) Make ONLY the minimal requested change — nothing else. \
|
2) Make ONLY the minimal requested change — nothing else. \
|
||||||
@@ -214,6 +229,14 @@ class AnytypeMCPService {
|
|||||||
let type_ = args["type"] as? String ?? "note"
|
let type_ = args["type"] as? String ?? "note"
|
||||||
return try await createObject(spaceId: spaceId, name: name, body: body, type: type_)
|
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":
|
case "anytype_update_object":
|
||||||
guard let spaceId = args["space_id"] as? String,
|
guard let spaceId = args["space_id"] as? String,
|
||||||
let objectId = args["object_id"] as? String else {
|
let objectId = args["object_id"] as? String else {
|
||||||
@@ -351,6 +374,29 @@ class AnytypeMCPService {
|
|||||||
return ["success": true, "message": "Object created"]
|
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] {
|
private func updateObject(spaceId: String, objectId: String, name: String?, body: String?) async throws -> [String: Any] {
|
||||||
var requestBody: [String: Any] = [:]
|
var requestBody: [String: Any] = [:]
|
||||||
if let name = name { requestBody["name"] = name }
|
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
|
// MARK: - Anytype MCP Settings
|
||||||
|
|
||||||
var anytypeMcpEnabled: Bool {
|
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
|
let newProvider = inferProvider(from: model.id) ?? currentProvider
|
||||||
selectedModel = model
|
selectedModel = model
|
||||||
currentProvider = newProvider
|
currentProvider = newProvider
|
||||||
settings.defaultModel = model.id
|
|
||||||
settings.defaultProvider = newProvider
|
|
||||||
MCPService.shared.resetBashSessionApproval()
|
MCPService.shared.resetBashSessionApproval()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
|
||||||
|
|
||||||
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
||||||
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
||||||
if modelId.contains("/") { return .openrouter }
|
if modelId.contains("/") { return .openrouter }
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ struct HeaderView: View {
|
|||||||
private let gitSync = GitSyncService.shared
|
private let gitSync = GitSyncService.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 20) {
|
||||||
// Provider picker dropdown — only shows configured providers
|
// Provider picker dropdown — only shows configured providers
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(registry.configuredProviders, id: \.self) { p in
|
ForEach(registry.configuredProviders, id: \.self) { p in
|
||||||
@@ -126,6 +126,17 @@ struct HeaderView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Select model")
|
.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()
|
Spacer()
|
||||||
|
|
||||||
// Status indicators
|
// Status indicators
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ struct ModelInfoView: View {
|
|||||||
let model: ModelInfo
|
let model: ModelInfo
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@Bindable private var settings = SettingsService.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -37,6 +38,15 @@ struct ModelInfoView: View {
|
|||||||
Text("Model Info")
|
Text("Model Info")
|
||||||
.font(.system(size: 18, weight: .bold))
|
.font(.system(size: 18, weight: .bold))
|
||||||
Spacer()
|
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: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ struct ModelSelectorView: View {
|
|||||||
@State private var filterOnline = false
|
@State private var filterOnline = false
|
||||||
@State private var filterImageGen = false
|
@State private var filterImageGen = false
|
||||||
@State private var filterThinking = false
|
@State private var filterThinking = false
|
||||||
|
@State private var filterFavorites = false
|
||||||
@State private var keyboardIndex: Int = -1
|
@State private var keyboardIndex: Int = -1
|
||||||
@State private var sortOrder: ModelSortOrder = .default
|
@State private var sortOrder: ModelSortOrder = .default
|
||||||
@State private var selectedInfoModel: ModelInfo? = nil
|
@State private var selectedInfoModel: ModelInfo? = nil
|
||||||
|
@Bindable private var settings = SettingsService.shared
|
||||||
|
|
||||||
private var filteredModels: [ModelInfo] {
|
private var filteredModels: [ModelInfo] {
|
||||||
let q = searchText.lowercased()
|
let q = searchText.lowercased()
|
||||||
@@ -54,13 +56,20 @@ struct ModelSelectorView: View {
|
|||||||
let matchesOnline = !filterOnline || model.capabilities.online
|
let matchesOnline = !filterOnline || model.capabilities.online
|
||||||
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
||||||
let matchesThinking = !filterThinking || model.capabilities.thinking
|
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 {
|
switch sortOrder {
|
||||||
case .default:
|
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:
|
case .priceLowHigh:
|
||||||
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
|
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
|
||||||
case .priceHighLow:
|
case .priceHighLow:
|
||||||
@@ -91,6 +100,19 @@ struct ModelSelectorView: View {
|
|||||||
|
|
||||||
Spacer()
|
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
|
// Sort menu
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
|
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
|
||||||
@@ -140,7 +162,9 @@ struct ModelSelectorView: View {
|
|||||||
model: model,
|
model: model,
|
||||||
isSelected: model.id == selectedModel?.id,
|
isSelected: model.id == selectedModel?.id,
|
||||||
isKeyboardHighlighted: index == keyboardIndex,
|
isKeyboardHighlighted: index == keyboardIndex,
|
||||||
|
isFavorite: settings.favoriteModelIds.contains(model.id),
|
||||||
onSelect: { onSelect(model) },
|
onSelect: { onSelect(model) },
|
||||||
|
onFavorite: { settings.toggleFavoriteModel(model.id) },
|
||||||
onInfo: { selectedInfoModel = model }
|
onInfo: { selectedInfoModel = model }
|
||||||
)
|
)
|
||||||
.id(model.id)
|
.id(model.id)
|
||||||
@@ -249,14 +273,25 @@ struct ModelRowView: View {
|
|||||||
let model: ModelInfo
|
let model: ModelInfo
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
var isKeyboardHighlighted: Bool = false
|
var isKeyboardHighlighted: Bool = false
|
||||||
|
var isFavorite: Bool = false
|
||||||
let onSelect: () -> Void
|
let onSelect: () -> Void
|
||||||
|
var onFavorite: (() -> Void)? = nil
|
||||||
let onInfo: () -> Void
|
let onInfo: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
// Selectable main content
|
// Selectable main content
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
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)
|
Text(model.name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(isSelected ? .blue : .primary)
|
.foregroundColor(isSelected ? .blue : .primary)
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ struct SettingsView: View {
|
|||||||
@State private var syncTestResult: String?
|
@State private var syncTestResult: String?
|
||||||
@State private var isSyncing = false
|
@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
|
// Paperless-NGX state
|
||||||
@State private var paperlessURL = ""
|
@State private var paperlessURL = ""
|
||||||
@State private var paperlessToken = ""
|
@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(2, icon: "paintbrush", label: "Appearance")
|
||||||
tabButton(3, icon: "slider.horizontal.3", label: "Advanced")
|
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(6, icon: "command", label: "Shortcuts")
|
||||||
tabButton(7, icon: "brain", label: "Skills")
|
tabButton(7, icon: "brain", label: "Skills")
|
||||||
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
|
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
|
||||||
tabButton(5, icon: "envelope", label: "Email")
|
tabButton(5, icon: "envelope", label: "Email")
|
||||||
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
|
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
|
||||||
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
||||||
|
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 12)
|
.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
|
paperlessTab
|
||||||
case 9:
|
case 9:
|
||||||
backupTab
|
backupTab
|
||||||
|
case 10:
|
||||||
|
anytypeTab
|
||||||
default:
|
default:
|
||||||
generalTab
|
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)
|
.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) {
|
.sheet(isPresented: $showEmailLog) {
|
||||||
EmailLogView()
|
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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Model Settings")
|
sectionHeader("Model Settings")
|
||||||
formSection {
|
formSection {
|
||||||
row("Default Model ID") {
|
row("Default Model") {
|
||||||
TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
|
HStack(spacing: 8) {
|
||||||
get: { settingsService.defaultModel ?? "" },
|
let modelName: String = {
|
||||||
set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
|
if let id = settingsService.defaultModel {
|
||||||
))
|
return chatViewModel?.availableModels.first(where: { $0.id == id })?.name ?? id
|
||||||
.textFieldStyle(.roundedBorder)
|
}
|
||||||
.frame(width: 300)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1909,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
|
// MARK: - Backup Tab
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -2112,9 +2265,9 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 68)
|
.frame(minWidth: 60)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 4)
|
||||||
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user