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

    +

    Favourite Models

    +

    Click the β˜… star next to any model name to mark it as a favourite. Favourites:

    + +

    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

    + + +

    Setup

    +
      +
    1. Open oAI Settings β†’ Anytype tab
    2. +
    3. Enable the toggle
    4. +
    5. Enter your API key (leave the URL as the default unless your setup is unusual)
    6. +
    7. Click Test Connection β€” a success message will show how many spaces were found
    8. +
    + +

    What the AI Can Do

    + + +
    + πŸ’‘ 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

    + +
    +

    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.

    + +

    When enabled, the AI has access to these tools:

    + +

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