Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 098c3c3d1e | |||
| 3d6ac578db | |||
| 0f9dc05774 | |||
| 3f9b30bfa1 | |||
| 375b8fb345 | |||
| 305abfa85d |
22
README.md
22
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)
|
||||||
@@ -338,6 +350,12 @@ Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instru
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
oAI takes real actions on your behalf — it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind — the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**⭐ Star this project if you find it useful!**
|
**⭐ Star this project if you find it useful!**
|
||||||
|
|
||||||
**🐛 Found a bug?** [Open an issue](https://gitlab.pm/rune/oai-swift/issues/new)
|
**🐛 Found a bug?** [Open an issue](https://gitlab.pm/rune/oai-swift/issues/new)
|
||||||
|
|||||||
@@ -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.6;
|
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.6;
|
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;
|
||||||
|
|||||||
@@ -160,6 +160,18 @@ struct OpenRouterChatResponse: Codable {
|
|||||||
let content: String?
|
let content: String?
|
||||||
let toolCalls: [APIToolCall]?
|
let toolCalls: [APIToolCall]?
|
||||||
let images: [ImageOutput]?
|
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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case role
|
case role
|
||||||
@@ -167,6 +179,27 @@ struct OpenRouterChatResponse: Codable {
|
|||||||
case toolCalls = "tool_calls"
|
case toolCalls = "tool_calls"
|
||||||
case images
|
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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
|
|||||||
@@ -160,8 +160,17 @@ class OpenRouterProvider: AIProvider {
|
|||||||
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
|
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)
|
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
|
// 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)
|
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(
|
return ChatResponse(
|
||||||
id: apiResponse.id,
|
id: apiResponse.id,
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -1265,6 +1265,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
|
|
||||||
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
|
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
|
||||||
let mcp = MCPService.shared
|
let mcp = MCPService.shared
|
||||||
|
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
|
||||||
isGenerating = true
|
isGenerating = true
|
||||||
streamingTask?.cancel()
|
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
|
let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit
|
||||||
var finalContent = ""
|
var finalContent = ""
|
||||||
|
var finalImages: [Data] = []
|
||||||
|
var didContinueAfterImages = false // Only inject temp-file continuation once
|
||||||
var totalUsage: ChatResponse.Usage?
|
var totalUsage: ChatResponse.Usage?
|
||||||
var hitIterationLimit = false // Track if we exited due to hitting the limit
|
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
|
let toolCalls = structuredCalls.isEmpty ? textCalls : structuredCalls
|
||||||
|
|
||||||
guard !toolCalls.isEmpty else {
|
guard !toolCalls.isEmpty else {
|
||||||
// No tool calls — this is the final text response
|
// No tool calls — this is the final response
|
||||||
// Strip any unparseable tool call text from display
|
|
||||||
finalContent = response.content
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1491,7 +1517,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
attachments: nil,
|
attachments: nil,
|
||||||
responseTime: responseTime,
|
responseTime: responseTime,
|
||||||
wasInterrupted: wasCancelled,
|
wasInterrupted: wasCancelled,
|
||||||
modelId: modelId
|
modelId: modelId,
|
||||||
|
generatedImages: finalImages.isEmpty ? nil : finalImages
|
||||||
)
|
)
|
||||||
messages.append(assistantMessage)
|
messages.append(assistantMessage)
|
||||||
|
|
||||||
@@ -1935,12 +1962,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
func detectGoodbyePhrase(in text: String) -> Bool {
|
func detectGoodbyePhrase(in text: String) -> Bool {
|
||||||
let lowercased = text.lowercased()
|
let lowercased = text.lowercased()
|
||||||
let goodbyePhrases = [
|
let goodbyePhrases = [
|
||||||
"bye", "goodbye", "bye bye",
|
"bye", "goodbye", "bye bye", "good bye",
|
||||||
"thanks", "thank you", "thx", "ty",
|
|
||||||
"that's all", "thats all", "that'll be all",
|
"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",
|
"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
|
return goodbyePhrases.contains { phrase in
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct ContentView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 640, minHeight: 400)
|
.frame(minWidth: 860, minHeight: 560)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
||||||
@@ -120,24 +120,24 @@ struct ContentView: View {
|
|||||||
private var macOSToolbar: some ToolbarContent {
|
private var macOSToolbar: some ToolbarContent {
|
||||||
let settings = SettingsService.shared
|
let settings = SettingsService.shared
|
||||||
let showLabels = settings.showToolbarLabels
|
let showLabels = settings.showToolbarLabels
|
||||||
let scale = iconScale(for: settings.toolbarIconSize)
|
let iconSize = settings.toolbarIconSize
|
||||||
|
|
||||||
ToolbarItemGroup(placement: .automatic) {
|
ToolbarItemGroup(placement: .automatic) {
|
||||||
// New conversation
|
// New conversation
|
||||||
Button(action: { chatViewModel.newConversation() }) {
|
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)
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
.help("New conversation")
|
.help("New conversation")
|
||||||
|
|
||||||
Button(action: { chatViewModel.showConversations = true }) {
|
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)
|
.keyboardShortcut("l", modifiers: .command)
|
||||||
.help("Saved conversations (Cmd+L)")
|
.help("Saved conversations (Cmd+L)")
|
||||||
|
|
||||||
Button(action: { chatViewModel.showHistory = true }) {
|
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)
|
.keyboardShortcut("h", modifiers: .command)
|
||||||
.help("Command history (Cmd+H)")
|
.help("Command history (Cmd+H)")
|
||||||
@@ -145,7 +145,7 @@ struct ContentView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: { chatViewModel.showModelSelector = true }) {
|
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)
|
.keyboardShortcut("m", modifiers: .command)
|
||||||
.help("Select AI model (Cmd+M)")
|
.help("Select AI model (Cmd+M)")
|
||||||
@@ -155,32 +155,32 @@ struct ContentView: View {
|
|||||||
chatViewModel.modelInfoTarget = model
|
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)
|
.keyboardShortcut("i", modifiers: .command)
|
||||||
.help("Model info (Cmd+I)")
|
.help("Model info (Cmd+I)")
|
||||||
.disabled(chatViewModel.selectedModel == nil)
|
.disabled(chatViewModel.selectedModel == nil)
|
||||||
|
|
||||||
Button(action: { chatViewModel.showStats = true }) {
|
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")
|
.help("Session statistics")
|
||||||
|
|
||||||
Button(action: { chatViewModel.showCredits = true }) {
|
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")
|
.help("Check API credits")
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: { chatViewModel.showSettings = true }) {
|
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)
|
.keyboardShortcut(",", modifiers: .command)
|
||||||
.help("Settings (Cmd+,)")
|
.help("Settings (Cmd+,)")
|
||||||
|
|
||||||
Button(action: { chatViewModel.showHelp = true }) {
|
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)
|
.keyboardShortcut("/", modifiers: .command)
|
||||||
.help("Help & commands (Cmd+/)")
|
.help("Help & commands (Cmd+/)")
|
||||||
@@ -188,14 +188,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#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
|
// Helper view for toolbar labels
|
||||||
@@ -203,17 +195,41 @@ struct ToolbarLabel: View {
|
|||||||
let title: LocalizedStringKey
|
let title: LocalizedStringKey
|
||||||
let systemImage: String
|
let systemImage: String
|
||||||
let showLabels: Bool
|
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 {
|
var body: some View {
|
||||||
if showLabels {
|
if iconSize > 32 {
|
||||||
Label(title, systemImage: systemImage)
|
// Extra-large: explicit font size above the system .large ceiling
|
||||||
.labelStyle(.titleAndIcon)
|
// Offset by 16 so slider 34→18pt, 36→20pt, 38→22pt, 40→24pt
|
||||||
.imageScale(scale)
|
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 {
|
} else {
|
||||||
Label(title, systemImage: systemImage)
|
// Original behaviour — imageScale keeps existing look intact
|
||||||
.labelStyle(.iconOnly)
|
if showLabels {
|
||||||
.imageScale(scale)
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -749,7 +791,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
formSection {
|
formSection {
|
||||||
row("Icon Size") {
|
row("Icon Size") {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Slider(value: $settingsService.toolbarIconSize, in: 16...32, step: 2)
|
Slider(value: $settingsService.toolbarIconSize, in: 16...40, step: 2)
|
||||||
.frame(maxWidth: 200)
|
.frame(maxWidth: 200)
|
||||||
Text("\(Int(settingsService.toolbarIconSize)) pt")
|
Text("\(Int(settingsService.toolbarIconSize)) pt")
|
||||||
.font(.system(size: 13))
|
.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)
|
Toggle("", isOn: $settingsService.paperlessEnabled)
|
||||||
.toggleStyle(.switch)
|
.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
|
// MARK: - Backup Tab
|
||||||
|
|
||||||
@ViewBuilder
|
@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)
|
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||||
if beta {
|
if beta {
|
||||||
Text("β")
|
Text("β")
|
||||||
.font(.system(size: 8, weight: .bold))
|
.font(.system(size: 9, weight: .heavy))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 3)
|
.padding(.horizontal, 4)
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 2)
|
||||||
.background(Color.orange)
|
.background(Color.orange)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.offset(x: 6, y: -2)
|
.offset(x: 8, y: -3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(label)
|
Text(label)
|
||||||
.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