Merge pull request 'New release v2.3.8' (#4) from 2.3.8 into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-03-05 13:18:24 +01:00
10 changed files with 393 additions and 28 deletions

View File

@@ -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
![Advanced Features](Screenshots/4.png) ![Advanced Features](Screenshots/4.png)
@@ -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)

View File

@@ -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;

View File

@@ -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 -->

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,7 +146,7 @@ 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")
@@ -144,6 +154,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
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))
} }