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

@@ -306,6 +316,8 @@ AI-powered email auto-responder:
- [x] Localization (Norwegian BokmΓ₯l, Swedish, Danish, German)
- [x] iCloud Backup (settings export/restore)
- [x] Bash execution with per-command approval
+- [x] Anytype integration (read, append, create, checkbox tools)
+- [x] Model favourites (starred models, filter, float to top)
- [ ] SOUL.md / USER.md β living identity documents injected into system prompt
- [ ] Parallel research agents (read-only, concurrent)
- [ ] Local embeddings (sentence-transformers, $0 cost)
diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj
index 127be75..a7570e5 100644
--- a/oAI.xcodeproj/project.pbxproj
+++ b/oAI.xcodeproj/project.pbxproj
@@ -283,7 +283,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
- MARKETING_VERSION = 2.3.7;
+ MARKETING_VERSION = 2.3.8;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -327,7 +327,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
- MARKETING_VERSION = 2.3.7;
+ MARKETING_VERSION = 2.3.8;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
index 31f5815..dfd00e2 100644
--- a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
+++ b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
@@ -35,6 +35,7 @@
Email Handler (AI Assistant)
Shortcuts (Prompt Templates)
Agent Skills (SKILL.md)
+ Anytype Integration
Bash Execution
iCloud Backup
Reasoning / Thinking Tokens
@@ -117,14 +118,23 @@
Sorting
Click the ββ Sort button to sort the list by:
- - Default β provider order
+ - Default β provider order, with favourites floated to the top
- Price: Low to High β cheapest per million tokens first
- Price: High to Low β most capable/expensive first
- Context: High to Low β largest context window first
+ Favourite Models
+ Click the β
star next to any model name to mark it as a favourite. Favourites:
+
+ - Float to the top of the Default sort order
+ - Can be filtered to show only with the β star button in the toolbar
+ - Are shown as a filled yellow star β
in the model row, the Model Info sheet, and the header bar
+ - Are shared across all three places β toggling in any one updates all
+
+
Model Information
- Click the β 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:
+ Click the β 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:
/info
Shows information about the currently selected model.
@@ -132,7 +142,7 @@
Use β / β to move through the list, Return to select the highlighted model.
Default Model
- Your selected model is automatically saved and will be restored when you restart the app.
+ Set a model that oAI always opens with in Settings β General β Model Settings β Default Model. Click Chooseβ¦ to pick from the full model list, or Clear to remove the default. Switching models during a chat session does not change your saved default β it only changes the current session.
@@ -1361,6 +1371,48 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
+
+
+ Anytype Integration
+ oAI can connect to your local Anytype 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.
+
+ Requirements
+
+ - Anytype desktop app installed and running
+ - An API key generated inside Anytype (Settings β Integrations)
+
+
+ Setup
+
+ - Open oAI Settings β Anytype tab
+ - Enable the toggle
+ - Enter your API key (leave the URL as the default unless your setup is unusual)
+ - Click Test Connection β a success message will show how many spaces were found
+
+
+ What the AI Can Do
+
+ - Search β find objects by keyword across all spaces or within a specific one
+ - Read β open any object and read its full markdown content
+ - Create β make new notes, tasks, or pages
+ - Append β add content to the end of an existing object without touching the rest (recommended for edits)
+ - Update β rewrite the full body of an object (use only when truly restructuring content)
+ - Checkboxes β toggle individual to-do checkboxes by text match, or mark tasks done via their native relation
+
+
+
+ π‘ Tip β Append vs Update: Use append 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. Update replaces the entire body and can degrade rich Anytype internal links (anytype://...) to plain text.
+
+
+ Example Prompts
+
+ - "Search my Anytype for notes about Swift concurrency"
+ - "Create a new task called 'Review PR #42' in my Work space"
+ - "Add today's meeting summary to my Weekly Notes object"
+ - "Mark the 'Buy groceries' checkbox as done in my Shopping List"
+
+
+
Keyboard Shortcuts
Work faster with these keyboard shortcuts.
@@ -1549,6 +1601,27 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
Restore from Fileβ¦ β imports settings from a backup file
API keys and credentials are excluded from backups and must be re-entered after restore
+
+ Anytype Tab
+ Connect oAI to your local Anytype desktop app so the AI can search, read, and add content to your knowledge base.
+
+ - Enable Anytype β toggle to activate the integration
+ - API URL β local Anytype API endpoint (default:
http://127.0.0.1:31009)
+ - API Key β generated in Anytype β Settings β Integrations
+ - Test Connection β verify connectivity and list available spaces
+
+ When enabled, the AI has access to these tools:
+
+ anytype_search_global / anytype_search_space β search across all or a specific space
+ anytype_list_spaces / anytype_get_space_objects β explore your spaces
+ anytype_get_object β read the full markdown body of any object
+ anytype_create_object β create a new note, task, or page
+ anytype_append_to_object β add content to an existing object without rewriting it (preferred for edits β preserves Anytype internal links)
+ anytype_update_object β replace the full body (use sparingly; prefer append)
+ anytype_toggle_checkbox β surgically check/uncheck a to-do item by text match
+ anytype_set_done β mark a task done/undone via its native relation
+
+ Note: The Anytype desktop app must be running for the integration to work. The API is local-only β no data leaves your machine.
diff --git a/oAI/Services/AnytypeMCPService.swift b/oAI/Services/AnytypeMCPService.swift
index 54c2f84..ed514ec 100644
--- a/oAI/Services/AnytypeMCPService.swift
+++ b/oAI/Services/AnytypeMCPService.swift
@@ -105,13 +105,28 @@ class AnytypeMCPService {
],
required: ["space_id", "name"]
),
+ makeTool(
+ name: "anytype_append_to_object",
+ description: """
+ Append new markdown content to the end of an existing Anytype object without touching the existing body. \
+ This is the PREFERRED way to add content to existing notes, pages, or tasks β \
+ it preserves all Anytype internal links (anytype://...) and mention blocks exactly. \
+ Use this instead of anytype_update_object whenever you are adding information rather than rewriting.
+ """,
+ properties: [
+ "space_id": prop("string", "The ID of the space containing the object"),
+ "object_id": prop("string", "The ID of the object to append to"),
+ "content": prop("string", "Markdown content to append at the end of the object body")
+ ],
+ required: ["space_id", "object_id", "content"]
+ ),
makeTool(
name: "anytype_update_object",
description: """
Replace the full markdown body or rename an Anytype object. \
- IMPORTANT: For toggling a checkbox (to-do item), use anytype_toggle_checkbox instead β \
- it is safer and does not risk modifying other content. \
- Use anytype_update_object ONLY for large content changes (adding paragraphs, rewriting sections, etc.). \
+ WARNING: This replaces the ENTIRE body β prefer anytype_append_to_object for adding content \
+ to existing objects, as full replacement degrades rich Anytype internal links (anytype://...) to plain text. \
+ Use this ONLY when you truly need to rewrite or restructure existing content. \
CRITICAL RULES when using this tool: \
1) Always call anytype_get_object first to get the current EXACT markdown. \
2) Make ONLY the minimal requested change β nothing else. \
@@ -214,6 +229,14 @@ class AnytypeMCPService {
let type_ = args["type"] as? String ?? "note"
return try await createObject(spaceId: spaceId, name: name, body: body, type: type_)
+ case "anytype_append_to_object":
+ guard let spaceId = args["space_id"] as? String,
+ let objectId = args["object_id"] as? String,
+ let content = args["content"] as? String else {
+ return ["error": "Missing required parameters: space_id, object_id, content"]
+ }
+ return try await appendToObject(spaceId: spaceId, objectId: objectId, content: content)
+
case "anytype_update_object":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String else {
@@ -351,6 +374,29 @@ class AnytypeMCPService {
return ["success": true, "message": "Object created"]
}
+ private func appendToObject(spaceId: String, objectId: String, content: String) async throws -> [String: Any] {
+ // Fetch current body
+ let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil)
+ guard let object = result["object"] as? [String: Any] else {
+ return ["error": "Object not found"]
+ }
+
+ let existing: String
+ if let md = object["markdown"] as? String { existing = md }
+ else if let body = object["body"] as? String { existing = body }
+ else { existing = "" }
+
+ let separator = existing.isEmpty ? "" : "\n\n"
+ let newMarkdown = existing + separator + content
+
+ _ = try await request(
+ endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)",
+ method: "PATCH",
+ body: ["markdown": newMarkdown]
+ )
+ return ["success": true, "message": "Content appended successfully"]
+ }
+
private func updateObject(spaceId: String, objectId: String, name: String?, body: String?) async throws -> [String: Any] {
var requestBody: [String: Any] = [:]
if let name = name { requestBody["name"] = name }
diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift
index b8918bd..0f1b0b2 100644
--- a/oAI/Services/SettingsService.swift
+++ b/oAI/Services/SettingsService.swift
@@ -430,6 +430,31 @@ class SettingsService {
}
}
+ // MARK: - Favorite Models
+
+ var favoriteModelIds: Set {
+ get {
+ guard let json = cache["favoriteModelIds"],
+ let data = json.data(using: .utf8),
+ let ids = try? JSONDecoder().decode([String].self, from: data) else { return [] }
+ return Set(ids)
+ }
+ set {
+ let sorted = newValue.sorted()
+ if let data = try? JSONEncoder().encode(sorted),
+ let json = String(data: data, encoding: .utf8) {
+ cache["favoriteModelIds"] = json
+ DatabaseService.shared.setSetting(key: "favoriteModelIds", value: json)
+ }
+ }
+ }
+
+ func toggleFavoriteModel(_ id: String) {
+ var favs = favoriteModelIds
+ if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
+ favoriteModelIds = favs
+ }
+
// MARK: - Anytype MCP Settings
var anytypeMcpEnabled: Bool {
diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift
index ee19e11..55fbc3f 100644
--- a/oAI/ViewModels/ChatViewModel.swift
+++ b/oAI/ViewModels/ChatViewModel.swift
@@ -417,11 +417,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
let newProvider = inferProvider(from: model.id) ?? currentProvider
selectedModel = model
currentProvider = newProvider
- settings.defaultModel = model.id
- settings.defaultProvider = newProvider
MCPService.shared.resetBashSessionApproval()
}
+ func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
+
private func inferProvider(from modelId: String) -> Settings.Provider? {
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
if modelId.contains("/") { return .openrouter }
diff --git a/oAI/Views/Main/HeaderView.swift b/oAI/Views/Main/HeaderView.swift
index c118784..741be09 100644
--- a/oAI/Views/Main/HeaderView.swift
+++ b/oAI/Views/Main/HeaderView.swift
@@ -39,7 +39,7 @@ struct HeaderView: View {
private let gitSync = GitSyncService.shared
var body: some View {
- HStack(spacing: 12) {
+ HStack(spacing: 20) {
// Provider picker dropdown β only shows configured providers
Menu {
ForEach(registry.configuredProviders, id: \.self) { p in
@@ -126,6 +126,17 @@ struct HeaderView: View {
.buttonStyle(.plain)
.help("Select model")
+ if let model = model {
+ let isFav = settings.favoriteModelIds.contains(model.id)
+ Button(action: { settings.toggleFavoriteModel(model.id) }) {
+ Image(systemName: isFav ? "star.fill" : "star")
+ .font(.system(size: settings.guiTextSize - 3))
+ .foregroundColor(isFav ? .yellow : .oaiSecondary)
+ }
+ .buttonStyle(.plain)
+ .help(isFav ? "Remove from favorites" : "Add to favorites")
+ }
+
Spacer()
// Status indicators
diff --git a/oAI/Views/Screens/ModelInfoView.swift b/oAI/Views/Screens/ModelInfoView.swift
index fc342bd..7e7a49b 100644
--- a/oAI/Views/Screens/ModelInfoView.swift
+++ b/oAI/Views/Screens/ModelInfoView.swift
@@ -29,6 +29,7 @@ struct ModelInfoView: View {
let model: ModelInfo
@Environment(\.dismiss) var dismiss
+ @Bindable private var settings = SettingsService.shared
var body: some View {
VStack(spacing: 0) {
@@ -37,6 +38,15 @@ struct ModelInfoView: View {
Text("Model Info")
.font(.system(size: 18, weight: .bold))
Spacer()
+ let isFav = settings.favoriteModelIds.contains(model.id)
+ Button(action: { settings.toggleFavoriteModel(model.id) }) {
+ Image(systemName: isFav ? "star.fill" : "star")
+ .font(.system(size: 18))
+ .foregroundColor(isFav ? .yellow : .secondary)
+ }
+ .buttonStyle(.plain)
+ .help(isFav ? "Remove from favorites" : "Add to favorites")
+ .padding(.trailing, 8)
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
diff --git a/oAI/Views/Screens/ModelSelectorView.swift b/oAI/Views/Screens/ModelSelectorView.swift
index c3a321d..770405c 100644
--- a/oAI/Views/Screens/ModelSelectorView.swift
+++ b/oAI/Views/Screens/ModelSelectorView.swift
@@ -37,9 +37,11 @@ struct ModelSelectorView: View {
@State private var filterOnline = false
@State private var filterImageGen = false
@State private var filterThinking = false
+ @State private var filterFavorites = false
@State private var keyboardIndex: Int = -1
@State private var sortOrder: ModelSortOrder = .default
@State private var selectedInfoModel: ModelInfo? = nil
+ @Bindable private var settings = SettingsService.shared
private var filteredModels: [ModelInfo] {
let q = searchText.lowercased()
@@ -54,13 +56,20 @@ struct ModelSelectorView: View {
let matchesOnline = !filterOnline || model.capabilities.online
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
let matchesThinking = !filterThinking || model.capabilities.thinking
+ let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
- return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking
+ return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites
}
+ let favIds = settings.favoriteModelIds
switch sortOrder {
case .default:
- return filtered
+ return filtered.sorted { a, b in
+ let aFav = favIds.contains(a.id)
+ let bFav = favIds.contains(b.id)
+ if aFav != bFav { return aFav }
+ return false
+ }
case .priceLowHigh:
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
case .priceHighLow:
@@ -91,6 +100,19 @@ struct ModelSelectorView: View {
Spacer()
+ // Favorites filter star
+ Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
+ Image(systemName: filterFavorites ? "star.fill" : "star")
+ .font(.caption)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(filterFavorites ? Color.yellow.opacity(0.25) : Color.gray.opacity(0.1))
+ .foregroundColor(filterFavorites ? .yellow : .secondary)
+ .cornerRadius(6)
+ }
+ .buttonStyle(.plain)
+ .help("Show favorites only")
+
// Sort menu
Menu {
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
@@ -140,7 +162,9 @@ struct ModelSelectorView: View {
model: model,
isSelected: model.id == selectedModel?.id,
isKeyboardHighlighted: index == keyboardIndex,
+ isFavorite: settings.favoriteModelIds.contains(model.id),
onSelect: { onSelect(model) },
+ onFavorite: { settings.toggleFavoriteModel(model.id) },
onInfo: { selectedInfoModel = model }
)
.id(model.id)
@@ -249,14 +273,25 @@ struct ModelRowView: View {
let model: ModelInfo
let isSelected: Bool
var isKeyboardHighlighted: Bool = false
+ var isFavorite: Bool = false
let onSelect: () -> Void
+ var onFavorite: (() -> Void)? = nil
let onInfo: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 8) {
// Selectable main content
VStack(alignment: .leading, spacing: 6) {
- HStack {
+ HStack(spacing: 6) {
+ if let onFavorite {
+ Button(action: onFavorite) {
+ Image(systemName: isFavorite ? "star.fill" : "star")
+ .font(.system(size: 13))
+ .foregroundColor(isFavorite ? .yellow : .secondary)
+ }
+ .buttonStyle(.plain)
+ .help(isFavorite ? "Remove from favorites" : "Add to favorites")
+ }
Text(model.name)
.font(.headline)
.foregroundColor(isSelected ? .blue : .primary)
diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift
index e682810..7e683ec 100644
--- a/oAI/Views/Screens/SettingsView.swift
+++ b/oAI/Views/Screens/SettingsView.swift
@@ -58,6 +58,16 @@ struct SettingsView: View {
@State private var syncTestResult: String?
@State private var isSyncing = false
+ // Anytype state
+ @State private var anytypeAPIKey = ""
+ @State private var anytypeURL = ""
+ @State private var showAnytypeKey = false
+ @State private var isTestingAnytype = false
+ @State private var anytypeTestResult: String?
+
+ // Default model picker state
+ @State private var showDefaultModelPicker = false
+
// Paperless-NGX state
@State private var paperlessURL = ""
@State private var paperlessToken = ""
@@ -136,14 +146,15 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
tabButton(2, icon: "paintbrush", label: "Appearance")
tabButton(3, icon: "slider.horizontal.3", label: "Advanced")
- Divider().frame(height: 44).padding(.horizontal, 8)
+ Divider().frame(height: 44).padding(.horizontal, 4)
tabButton(6, icon: "command", label: "Shortcuts")
tabButton(7, icon: "brain", label: "Skills")
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
tabButton(5, icon: "envelope", label: "Email")
- tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
- tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
+ tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
+ tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
+ tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
@@ -173,6 +184,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
paperlessTab
case 9:
backupTab
+ case 10:
+ anytypeTab
default:
generalTab
}
@@ -183,6 +196,20 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
.frame(minWidth: 860, idealWidth: 940, minHeight: 620, idealHeight: 760)
+ .sheet(isPresented: $showDefaultModelPicker) {
+ ModelSelectorView(
+ models: chatViewModel?.availableModels ?? [],
+ selectedModel: chatViewModel?.availableModels.first(where: { $0.id == settingsService.defaultModel }),
+ onSelect: { model in
+ let provider = chatViewModel.flatMap { vm in
+ vm.inferProviderPublic(from: model.id)
+ } ?? settingsService.defaultProvider
+ settingsService.defaultModel = model.id
+ settingsService.defaultProvider = provider
+ showDefaultModelPicker = false
+ }
+ )
+ }
.sheet(isPresented: $showEmailLog) {
EmailLogView()
}
@@ -364,13 +391,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Model Settings")
formSection {
- row("Default Model ID") {
- TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
- get: { settingsService.defaultModel ?? "" },
- set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
- ))
- .textFieldStyle(.roundedBorder)
- .frame(width: 300)
+ row("Default Model") {
+ HStack(spacing: 8) {
+ let modelName: String = {
+ if let id = settingsService.defaultModel {
+ return chatViewModel?.availableModels.first(where: { $0.id == id })?.name ?? id
+ }
+ return "Not set"
+ }()
+ Text(modelName)
+ .foregroundStyle(settingsService.defaultModel == nil ? .secondary : .primary)
+ .frame(maxWidth: 240, alignment: .leading)
+ Button("Chooseβ¦") { showDefaultModelPicker = true }
+ .buttonStyle(.borderless)
+ if settingsService.defaultModel != nil {
+ Button("Clear") {
+ settingsService.defaultModel = nil
+ settingsService.defaultProvider = .openrouter
+ }
+ .buttonStyle(.borderless)
+ .foregroundStyle(.secondary)
+ }
+ }
}
}
}
@@ -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
@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))
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
}
- .frame(minWidth: 68)
+ .frame(minWidth: 60)
.padding(.vertical, 6)
- .padding(.horizontal, 6)
+ .padding(.horizontal, 4)
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
}