From fb15e31480ae700760c5e7940ca8c72f849a29a8 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 26 Sep 2026 23:46:48 +0000 Subject: [PATCH 1/5] feat(ai): edit AI providers on settings sub-pages instead of stacked modals Co-authored-by: Christian Bager Bach Houmann --- docs/src/content/docs/docs/AIAssistant.md | 61 +- docs/src/content/docs/docs/QuickAddAPI.md | 4 +- src/ai/aiHelpers.resolveModel.test.ts | 2 +- src/ai/aiHelpers.ts | 8 +- ...odelSyncService.syncStoredProvider.test.ts | 79 ++ src/ai/modelSyncService.ts | 52 ++ src/ai/providerConnection.ts | 3 +- ...tProvidersModal.audit-ai-assistant.test.ts | 175 ----- .../AIAssistantProvidersModal.discard.test.ts | 190 ----- .../AIAssistantProvidersModal.sync.test.ts | 296 -------- src/gui/AIAssistantProvidersModal.ts | 680 ------------------ src/gui/AIAssistantProvidersModal.ux.test.ts | 278 ------- src/gui/AIAssistantSettingsModal.ts | 182 ----- src/gui/ProviderPickerModal.ts | 2 +- src/gui/ai/AIProviderSettingPage.test.ts | 290 ++++++++ src/gui/ai/AIProviderSettingPage.ts | 541 ++++++++++++++ src/gui/ai/aiAssistantSettingsPage.test.ts | 153 ++++ src/gui/ai/aiAssistantSettingsPage.ts | 229 ++++++ src/gui/ai/aiSettingsState.test.ts | 37 + src/gui/ai/aiSettingsState.ts | 94 +++ src/gui/ai/providerSettings.ts | 25 +- src/gui/ai/syncStatus.test.ts | 5 +- src/gui/ai/syncStatus.ts | 7 +- src/gui/ai/systemPromptFields.test.ts | 56 +- src/gui/choiceList/ChoiceView.aiEntry.test.ts | 34 +- src/gui/choiceList/ChoiceView.svelte | 19 +- src/gui/components/settingsDefinitions.ts | 15 +- src/quickAddSettingsTab.aiPage.test.ts | 123 ++++ src/quickAddSettingsTab.audit-cleanup.test.ts | 2 +- src/quickAddSettingsTab.test.ts | 4 +- src/quickAddSettingsTab.ts | 64 +- src/styles.css | 53 +- src/utils/openPluginSettings.ts | 36 +- tests/obsidian-stub.ts | 62 +- 34 files changed, 1955 insertions(+), 1906 deletions(-) create mode 100644 src/ai/modelSyncService.syncStoredProvider.test.ts delete mode 100644 src/gui/AIAssistantProvidersModal.audit-ai-assistant.test.ts delete mode 100644 src/gui/AIAssistantProvidersModal.discard.test.ts delete mode 100644 src/gui/AIAssistantProvidersModal.sync.test.ts delete mode 100644 src/gui/AIAssistantProvidersModal.ts delete mode 100644 src/gui/AIAssistantProvidersModal.ux.test.ts delete mode 100644 src/gui/AIAssistantSettingsModal.ts create mode 100644 src/gui/ai/AIProviderSettingPage.test.ts create mode 100644 src/gui/ai/AIProviderSettingPage.ts create mode 100644 src/gui/ai/aiAssistantSettingsPage.test.ts create mode 100644 src/gui/ai/aiAssistantSettingsPage.ts create mode 100644 src/gui/ai/aiSettingsState.test.ts create mode 100644 src/gui/ai/aiSettingsState.ts create mode 100644 src/quickAddSettingsTab.aiPage.test.ts diff --git a/docs/src/content/docs/docs/AIAssistant.md b/docs/src/content/docs/docs/AIAssistant.md index 179130fe8..131665f0f 100644 --- a/docs/src/content/docs/docs/AIAssistant.md +++ b/docs/src/content/docs/docs/AIAssistant.md @@ -14,18 +14,25 @@ By the end you have an AI step wired into a choice: generate a note title, summarize a selection, or answer a question from your vault. :::note -The AI settings button and AI requests are available only when **Disable AI & +The AI Assistant settings and AI requests are available only when **Disable AI & online features** is turned off in QuickAdd settings. ::: ## Setup {#setup} 1. Create a folder for AI prompt templates, for example `AI prompts`. -2. Open QuickAdd settings and turn off **Disable AI & online features** (under **AI & online**). -3. In the choice list, click the **Configure AI Assistant** icon button. It uses the sparkles icon at the bottom of the list. -4. Set **Prompt template folder path** to the folder you created. -5. Click **Edit providers** and configure at least one provider. OpenAI and Gemini are already listed: click **Edit**, link an API key secret with **Link...**, click **Sync now** to pull the provider's current models, and **Save**. See [Connect a provider](#providers-and-local-models) for other providers. -6. Choose a **Default model**, or leave it as **Ask me** to pick a model each run. +2. Open **Settings → QuickAdd**, turn off **Disable AI & online features** + (under **AI & online**), and open **AI Assistant** just below it. The + **Configure AI Assistant** button in the choice list (the sparkles icon at + the bottom of the list) opens the same page. +3. Set **Prompt template folder** to the folder you created. +4. Under **Providers**, set up at least one provider. OpenAI and Gemini are + already listed: open one, link an API key secret with **Link...**, and click + **Sync now** to pull the provider's current models. To add another provider, + click **+**. See [Connect a provider](#providers-and-local-models). +5. Choose a **Default model**, or leave it as **Ask me** to pick a model each run. + +Changes on these pages save as you make them. ![Setting up the AI Assistant: enabling AI features, setting the prompt template folder, linking an OpenAI API key, syncing models, and choosing gpt-6-luna as the default model](./Images/AI_Assistant_Setup.gif) @@ -60,7 +67,7 @@ Some of these settings are read live on every run; two of them are only a template for new commands. The difference matters, so it is called out per setting: -- **Prompt template folder path** is the folder QuickAdd reads prompt-template notes from. Read live on every run. +- **Prompt template folder** is the folder QuickAdd reads prompt-template notes from. Read live on every run. - **Providers** is the list of model endpoints and model ids QuickAdd can use. Read live on every run. - **Default model** and **Default system prompt** are the starting values for **new** AI Assistant Macro commands: they are copied into a command when you add it. Editing a default later does not change commands you already created - edit each command instead. Setting the default model to **Ask me** makes new commands open a model picker at run time. - **Show assistant** controls QuickAdd's AI progress notices. Read live on every run. @@ -113,10 +120,10 @@ are migrated to SecretStorage. ### Add a provider {#add-a-provider} -1. Open **AI Assistant settings**. -2. Click **Edit providers**. -3. Click **Add provider**. -4. Pick a provider card, select a SecretStorage entry for the API key, then click **Connect**. +1. Open **Settings → QuickAdd → AI Assistant**. +2. Next to **Providers**, click **+** (on mobile, tap **Add provider** below + the list). +3. Pick a provider card, select a SecretStorage entry for the API key, then click **Connect**. Connecting a provider imports its current model list right away, so you can pick a working model immediately. If the live import fails (for example, while @@ -124,7 +131,7 @@ offline), the built-in providers fall back to a shipped model list and refresh automatically once the provider is reachable. For a provider that is not listed, click **Add custom...** under **Custom -provider**. Set the provider name, endpoint, API key secret if needed, model +provider**, then open the new provider in the list. Set its name, endpoint, API key secret if needed, model source, and models manually. To check a key, open the provider and click **Test connection**. QuickAdd asks @@ -151,14 +158,15 @@ requests still include an empty `Bearer` header. If your local server rejects that, configure the server to allow it or select a SecretStorage entry with the token it expects. -When adding a model manually, the model name must match the id your server -expects, such as `mistral` or `llama3.1`. The **Max tokens** value is the model's -context window. See [Model settings and token budgets](#model-settings-and-token-budgets). +When adding a model manually (the **+** next to **Models** on the provider's +page), the model name must match the id your server expects, such as `mistral` +or `llama3.1`. The **Context window** value is the model's context window in +tokens. See [Model settings and token budgets](#model-settings-and-token-budgets). ### One name, two providers {#provider-ids-and-duplicate-model-names} Every provider has a stable **ID** - a short slug like `openai` or `my-proxy`, -shown in the provider's edit form. The ID never changes, even if you rename the +shown under the provider's **Name** on its settings page. The ID never changes, even if you rename the provider, and scripts use it to address a model on a specific provider. Two providers can serve models with the same name - for example, the official @@ -187,8 +195,9 @@ each model's context window, output limit, sampling support, and release date where the source reports them. The model list shows the newest models first and has a filter box. Models the -provider has deprecated are marked **Retired by the provider** and listed last, -and **Remove retired models** clears them in one step. QuickAdd never removes +provider has deprecated carry a **Retired** badge and are listed last, the +provider's entry on the AI Assistant page shows a warning with the count, and +**Remove retired** clears them in one step. QuickAdd never removes them on its own, because saved commands may still use them. If model import fails, you can still add models manually. Use the provider's @@ -198,13 +207,13 @@ exact model id and the model's context-window token count. Each provider has an **Auto-sync models** toggle. While it is on, QuickAdd imports new models and refreshed context limits from the provider's model source -once a day and whenever provider settings open, so model lists stay current +once a day and whenever you open the provider's page, so model lists stay current without plugin updates. Auto-sync only adds models and updates metadata - it never removes models you have configured, and it does not add models the directory already marks as deprecated. Use **Sync now** to refresh on demand. The line under the toggle shows when the provider last synced, or why the last sync failed. -Models that arrive while you are editing a provider appear in its list right +Models that arrive while the provider's page is open appear in its list right away, and the **Sync now** notice counts every model added to the list you were looking at when you clicked it. @@ -216,7 +225,8 @@ features** is on. ### Max tokens is the context window {#max-tokens} -In the provider model list, **Max tokens** means the model's context window. It +In the provider's model list, **Context** (stored as `maxTokens`) is the +model's context window. It is the total amount of prompt plus response context the model can handle, according to the configured provider metadata or the value you entered manually. @@ -514,15 +524,16 @@ For the full script API surface, see the ### The AI settings button is missing {#the-ai-settings-button-is-missing} -Turn off **Disable AI & online features** in QuickAdd settings. The AI settings -button is hidden while AI and online features are disabled. With no choices yet, +Turn off **Disable AI & online features** in QuickAdd settings. The **AI +Assistant** page and its buttons are hidden while AI and online features are +disabled. With no choices yet, the button is **Configure AI Assistant** below **New choice**; otherwise it is the sparkles icon in the bar under the choice list. ### My model is not listed {#my-model-is-not-listed} -Open **AI Assistant settings** > **Edit providers** > your provider > **Edit**, -then click **Sync now**. Providers with **Auto-sync models** on pick up new +Open **Settings → QuickAdd → AI Assistant** > your provider, then click +**Sync now**. Providers with **Auto-sync models** on pick up new models automatically once a day. You can also browse and import models, or add the model manually - the model name must exactly match what the provider expects. diff --git a/docs/src/content/docs/docs/QuickAddAPI.md b/docs/src/content/docs/docs/QuickAddAPI.md index 723485066..70c88056f 100644 --- a/docs/src/content/docs/docs/QuickAddAPI.md +++ b/docs/src/content/docs/docs/QuickAddAPI.md @@ -554,7 +554,7 @@ Sends a prompt to an AI model and returns the response. **Parameters:** - `prompt`: The prompt text -- `model`: Model identifier. The model must be configured in QuickAdd's AI Assistant settings, under Edit providers. Accepts: +- `model`: Model identifier. The model must be configured under one of the providers in **Settings → QuickAdd → AI Assistant**. Accepts: - A model name string, e.g. `"gpt-4o"`. Resolves to the first provider that serves it. - A provider-qualified string, e.g. `"openai/gpt-4o"`, where the prefix is a provider's stable ID (shown in the provider's edit form) or display name. Use this when two providers serve the same model name. If a provider literally serves a model whose id IS the whole string (OpenRouter's `openai/gpt-4o`, for example), that literal model wins - use the object form to override. - An object, e.g. `{name: "gpt-4o", provider: "openai"}`. With `provider` set, the lookup is scoped to exactly that provider and cannot be shadowed by literal slash-named models. @@ -603,7 +603,7 @@ const result = await quickAddApi.ai.prompt( ); ``` -**Note:** For newer models like `gpt-4o` or custom provider models, add them under Edit providers in QuickAdd's AI Assistant settings. Some providers support auto-sync to automatically update available models. +**Note:** For newer models like `gpt-4o` or custom provider models, add them to the provider in **Settings → QuickAdd → AI Assistant**. Some providers support auto-sync to automatically update available models. ### `chunkedPrompt(text: string, promptTemplate: string, model: string | {name: string, provider?: string}, settings?: object): Promise` Splits `text` into chunks, runs `promptTemplate` once per chunk, and joins the diff --git a/src/ai/aiHelpers.resolveModel.test.ts b/src/ai/aiHelpers.resolveModel.test.ts index 64538f979..d938549bd 100644 --- a/src/ai/aiHelpers.resolveModel.test.ts +++ b/src/ai/aiHelpers.resolveModel.test.ts @@ -281,7 +281,7 @@ describe("resolveModelInputOrThrow", () => { // group that has never existed — and the generic prefix above passed either // way, so nothing caught it. Pin the route the docs actually document. expect(() => resolveModelInputOrThrow("claude-x")).toThrow( - /Edit providers in QuickAdd's AI Assistant settings/, + /Settings → QuickAdd → AI Assistant → your provider/, ); expect(() => resolveModelInputOrThrow("claude-x")).not.toThrow( /QuickAdd . AI . Providers/, diff --git a/src/ai/aiHelpers.ts b/src/ai/aiHelpers.ts index bcb922fd9..f6bc6d5b9 100644 --- a/src/ai/aiHelpers.ts +++ b/src/ai/aiHelpers.ts @@ -213,11 +213,9 @@ export function resolveModelInputOrThrow( ]; const hint = candidates.length ? ` Did you mean ${candidates.join(" or ")}?` - // There has never been an "AI" settings group. Providers live behind the - // sparkles "Configure AI Assistant" button under the choice list, and - // auto-sync is a per-provider flag in the same modal — so the old path - // sent people somewhere that does not exist. - : " Add it under Edit providers in QuickAdd's AI Assistant settings (the sparkles button below the choice list), or enable auto-sync for that provider."; + // Providers live on their own settings sub-page, and auto-sync is a + // per-provider toggle there. + : " Add it in Settings → QuickAdd → AI Assistant → your provider, or turn on Auto-sync models for that provider."; throw new Error( `Model '${typeof input === "string" ? input : `${input.provider ?? ""}/${name}`}' not found in configured providers.${hint}`, ); diff --git a/src/ai/modelSyncService.syncStoredProvider.test.ts b/src/ai/modelSyncService.syncStoredProvider.test.ts new file mode 100644 index 000000000..7a45360cd --- /dev/null +++ b/src/ai/modelSyncService.syncStoredProvider.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AIProvider, Model } from "./Provider"; +import { DEFAULT_SETTINGS } from "src/settings"; +import { settingsStore } from "src/settingsStore"; +import { deepClone } from "src/utils/deepClone"; + +const discovery = vi.hoisted(() => ({ discover: vi.fn() })); +vi.mock("./modelDiscoveryService", () => ({ discoverProviderModels: discovery.discover })); +vi.mock("./providerSecrets", () => ({ resolveProviderApiKey: vi.fn(async () => "key") })); + +import { syncStoredProvider } from "./modelSyncService"; + +function provider(id: string, models: Model[]): AIProvider { + return { id, name: id, endpoint: `https://${id}.example`, apiKey: "", modelSource: "providerApi", models }; +} + +function install(...providers: AIProvider[]): void { + settingsStore.setState((state) => ({ ai: { ...state.ai, providers } })); +} + +describe("syncStoredProvider", () => { + beforeEach(() => { + settingsStore.replaceState(deepClone(DEFAULT_SETTINGS)); + discovery.discover.mockReset(); + }); + + it("merges into the provider as it exists when discovery returns", async () => { + let resolve!: (models: Model[]) => void; + discovery.discover.mockReturnValue(new Promise((done) => { resolve = done; })); + install(provider("target", [ + { name: "rename-me", maxTokens: 1 }, + { name: "delete-me", maxTokens: 2 }, + ])); + const pending = syncStoredProvider(undefined, "target"); + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [ + { ...state.ai.providers[0], name: "User rename", models: [{ name: "renamed", maxTokens: 9 }] }, + ] } })); + resolve([{ name: "discovered", maxTokens: 100 }]); + await pending; + + const current = settingsStore.getState().ai.providers[0]; + expect(current.name).toBe("User rename"); + expect(current.models.map((m) => m.name)).toEqual(["renamed", "discovered"]); + expect(current.lastModelSync).toEqual({ at: expect.any(Number) }); + }); + + it("records failure, rethrows it, and leaves models alone", async () => { + const error = new Error("offline"); + discovery.discover.mockRejectedValue(error); + install(provider("target", [{ name: "kept", maxTokens: 7 }])); + + await expect(syncStoredProvider(undefined, "target")).rejects.toBe(error); + const current = settingsStore.getState().ai.providers[0]; + expect(current.models).toEqual([{ name: "kept", maxTokens: 7 }]); + expect(current.lastModelSync).toEqual({ at: expect.any(Number), error: "offline" }); + }); + + it("returns null when the provider is removed while discovery is pending", async () => { + let resolve!: (models: Model[]) => void; + discovery.discover.mockReturnValue(new Promise((done) => { resolve = done; })); + install(provider("target", [])); + const pending = syncStoredProvider(undefined, "target"); + install(); + resolve([{ name: "late", maxTokens: 1 }]); + + await expect(pending).resolves.toBeNull(); + expect(settingsStore.getState().ai.providers).toEqual([]); + }); + + it("never mutates another provider", async () => { + const other = provider("other", [{ name: "other-model", maxTokens: 42 }]); + install(provider("target", []), other); + discovery.discover.mockResolvedValue([{ name: "new", maxTokens: 10 }]); + + await syncStoredProvider(undefined, "target"); + expect(settingsStore.getState().ai.providers[1]).toBe(other); + expect(settingsStore.getState().ai.providers[1].lastModelSync).toBeUndefined(); + }); +}); diff --git a/src/ai/modelSyncService.ts b/src/ai/modelSyncService.ts index 83f7da4e6..1e512331b 100644 --- a/src/ai/modelSyncService.ts +++ b/src/ai/modelSyncService.ts @@ -65,6 +65,58 @@ export async function syncProviderModels( return { ...diffModelLists(before, provider.models), discovered }; } +/** + * Sync one stored provider and merge the result into the store by id. The + * request runs against a detached copy, and the discovered models are merged + * into the provider as it is when the request returns, so edits made in the + * meantime survive (a model the user deleted comes back only if the source + * still lists it). The outcome is recorded in `lastModelSync` either way. + * Rethrows on failure; resolves to the discovered models, or null when the + * provider was removed before the request returned. + */ +export async function syncStoredProvider( + app: App | undefined, + providerId: string, +): Promise { + const stored = settingsStore + .getState() + .ai.providers.find((p) => p.id === providerId); + if (!stored) return null; + const copy: AIProvider = { + ...stored, + models: stored.models.map((model) => ({ ...model })), + }; + + let discovered: Model[] | undefined; + let failure: { error: unknown } | undefined; + try { + ({ discovered } = await syncProviderModels(app, copy)); + } catch (error) { + failure = { error }; + } + + let present = false; + settingsStore.setState((current) => ({ + ai: { + ...current.ai, + providers: current.ai.providers.map((provider) => { + if (provider.id !== providerId) return provider; + present = true; + return { + ...provider, + models: discovered + ? mergeSyncedModels(provider.models, discovered) + : provider.models, + lastModelSync: copy.lastModelSync, + }; + }), + }, + })); + + if (failure) throw failure.error; + return present ? (discovered ?? []) : null; +} + /** * Nonempty message for a failed sync. An empty `Error("")` message must not * be stored as `error: ""` — consumers treat that as success via truthiness. diff --git a/src/ai/providerConnection.ts b/src/ai/providerConnection.ts index d188f46fa..dc5d7829c 100644 --- a/src/ai/providerConnection.ts +++ b/src/ai/providerConnection.ts @@ -37,7 +37,8 @@ export async function testProviderConnection( /** The one-line result shown under the provider's Connection setting. */ export function describeConnectionResult(result: ProviderConnectionResult): string { if (result.ok) { - return `✓ Connected. The provider lists ${result.modelCount} model(s).`; + const models = `${result.modelCount} model${result.modelCount === 1 ? "" : "s"}`; + return `✓ Connected. The provider lists ${models}.`; } return `✗ ${result.error}${result.apiKeyLinked ? "" : " (No API key is linked.)"}`; } diff --git a/src/gui/AIAssistantProvidersModal.audit-ai-assistant.test.ts b/src/gui/AIAssistantProvidersModal.audit-ai-assistant.test.ts deleted file mode 100644 index ceaa5bbd8..000000000 --- a/src/gui/AIAssistantProvidersModal.audit-ai-assistant.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { App, ButtonComponent } from "obsidian"; -import type { AIProvider } from "src/ai/Provider"; - -const mocks = vi.hoisted(() => ({ - genericInputPromptMock: vi.fn(), -})); - -vi.mock("./GenericInputPrompt/GenericInputPrompt", () => ({ - default: { Prompt: mocks.genericInputPromptMock }, -})); - -vi.mock("./GenericYesNoPrompt/GenericYesNoPrompt", () => ({ - default: { Prompt: vi.fn().mockResolvedValue(true) }, -})); - -import { AIAssistantProvidersModal } from "./AIAssistantProvidersModal"; - -function provider(): AIProvider { - return { - name: "Custom", - endpoint: "https://api.custom.ai/v1", - apiKey: "", - apiKeyRef: "", - models: [], - modelSource: "providerApi", - }; -} - -function clickButtonByText(modal: AIAssistantProvidersModal, text: string) { - const button = Array.from( - modal.contentEl.querySelectorAll("button"), - ).find((candidate) => candidate.textContent === text); - if (!button) throw new Error(`Button "${text}" not found`); - button.click(); -} - -// Enter edit mode for the single provider, then return the modal so callers can -// drive the "Add model" flow. -function openProviderEdit(providers: AIProvider[]): AIAssistantProvidersModal { - const modal = new AIAssistantProvidersModal(providers, new App() as App); - clickButtonByText(modal, "Edit"); - return modal; -} - -// Finding: ai-assistant-provider-models-crud — Add Model previously appended a -// model with maxTokens: NaN for empty/non-numeric input and let prompt -// cancellation surface as an unhandled rejection. It must now validate and treat -// cancellation as a clean no-op. -describe("AIAssistantProvidersModal Add Model validation", () => { - beforeAll(() => { - const modalProto = Object.getPrototypeOf( - AIAssistantProvidersModal.prototype, - ) as { onClose?: () => void }; - modalProto.onClose ??= function onClose() {}; - - // The shared obsidian stub's ButtonComponent lacks these chainable - // methods; shim them locally (documented harness gap) so the providers - // modal can render without touching the shared stub. - const btnProto = ButtonComponent.prototype as unknown as { - setDestructive?: () => unknown; - setIcon?: () => unknown; - }; - btnProto.setDestructive ??= function setDestructive(this: unknown) { - return this; - }; - btnProto.setIcon ??= function setIcon(this: unknown) { - return this; - }; - }); - - beforeEach(() => { - mocks.genericInputPromptMock.mockReset(); - }); - - afterEach(() => { - document.body.empty?.(); - document.body.innerHTML = ""; - }); - - it("rejects a non-numeric max-tokens value instead of pushing maxTokens: NaN", async () => { - const providers = [provider()]; - const modal = openProviderEdit(providers); - - mocks.genericInputPromptMock - .mockResolvedValueOnce("gpt-4o") // name - .mockResolvedValueOnce("not-a-number"); // max tokens - - clickButtonByText(modal, "Add model"); - await vi.waitFor(() => - expect(mocks.genericInputPromptMock).toHaveBeenCalledTimes(2), - ); - // Let the onClick continuation run. - await Promise.resolve(); - await Promise.resolve(); - - expect(providers[0].models).toHaveLength(0); - }); - - it("rejects a numeric value with a trailing suffix (e.g. '10abc') rather than coercing it", async () => { - const providers = [provider()]; - const modal = openProviderEdit(providers); - - mocks.genericInputPromptMock - .mockResolvedValueOnce("gpt-4o") // name - .mockResolvedValueOnce("10abc"); // parseInt would coerce this to 10 - - clickButtonByText(modal, "Add model"); - await vi.waitFor(() => - expect(mocks.genericInputPromptMock).toHaveBeenCalledTimes(2), - ); - await Promise.resolve(); - await Promise.resolve(); - - expect(providers[0].models).toHaveLength(0); - }); - - it("rejects an empty model name", async () => { - const providers = [provider()]; - const modal = openProviderEdit(providers); - - mocks.genericInputPromptMock - .mockResolvedValueOnce(" ") // blank name - .mockResolvedValueOnce("8000"); - - clickButtonByText(modal, "Add model"); - await vi.waitFor(() => - expect(mocks.genericInputPromptMock).toHaveBeenCalledTimes(2), - ); - await Promise.resolve(); - await Promise.resolve(); - - expect(providers[0].models).toHaveLength(0); - }); - - it("treats prompt cancellation as a clean no-op (no unhandled rejection)", async () => { - const providers = [provider()]; - const modal = openProviderEdit(providers); - - mocks.genericInputPromptMock - .mockResolvedValueOnce("gpt-4o") - .mockRejectedValueOnce(new Error("No input given.")); - - clickButtonByText(modal, "Add model"); - await vi.waitFor(() => - expect(mocks.genericInputPromptMock).toHaveBeenCalledTimes(2), - ); - await Promise.resolve(); - await Promise.resolve(); - - expect(providers[0].models).toHaveLength(0); - }); - - it("adds a valid model with a positive integer max-tokens", async () => { - const providers = [provider()]; - const modal = openProviderEdit(providers); - - mocks.genericInputPromptMock - .mockResolvedValueOnce("gpt-4o") - .mockResolvedValueOnce("128000"); - - clickButtonByText(modal, "Add model"); - await vi.waitFor(() => - expect(mocks.genericInputPromptMock).toHaveBeenCalledTimes(2), - ); - await Promise.resolve(); - await Promise.resolve(); - - await vi.waitFor(() => expect(providers[0].models).toHaveLength(1)); - expect(providers[0].models[0]).toEqual({ - name: "gpt-4o", - maxTokens: 128000, - }); - }); -}); diff --git a/src/gui/AIAssistantProvidersModal.discard.test.ts b/src/gui/AIAssistantProvidersModal.discard.test.ts deleted file mode 100644 index 1175afd41..000000000 --- a/src/gui/AIAssistantProvidersModal.discard.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { App, ButtonComponent } from "obsidian"; -import type { AIProvider } from "src/ai/Provider"; - -vi.mock("./GenericInputPrompt/GenericInputPrompt", () => ({ - default: { Prompt: vi.fn() }, -})); -vi.mock("./GenericYesNoPrompt/GenericYesNoPrompt", () => ({ - default: { Prompt: vi.fn().mockResolvedValue(true) }, -})); - -import { AIAssistantProvidersModal } from "./AIAssistantProvidersModal"; - -function provider(): AIProvider { - return { - name: "Custom", - endpoint: "https://api.custom.ai/v1", - apiKey: "", - apiKeyRef: "", - models: [], - modelSource: "providerApi", - }; -} - -// A provider shaped like the built-in defaults: no apiKeyRef key at all. -function providerWithoutApiKeyRef(): AIProvider { - return { - name: "Custom", - endpoint: "https://api.custom.ai/v1", - apiKey: "", - models: [], - modelSource: "providerApi", - }; -} - -function providerWithModels(): AIProvider { - return { - name: "Custom", - endpoint: "https://api.custom.ai/v1", - apiKey: "", - apiKeyRef: "", - models: [{ name: "m1", maxTokens: 1000 }], - modelSource: "providerApi", - }; -} - -function clickButtonByText(modal: AIAssistantProvidersModal, text: string) { - const button = Array.from( - modal.contentEl.querySelectorAll("button"), - ).find((candidate) => candidate.textContent === text); - if (!button) throw new Error(`Button "${text}" not found`); - button.click(); -} - -function openProviderEdit(providers: AIProvider[]): AIAssistantProvidersModal { - const modal = new AIAssistantProvidersModal(providers, new App() as App); - clickButtonByText(modal, "Edit"); - return modal; -} - -// Type the new provider name into the edit form's Name field (the only input -// whose value matches the current name) and fire the onChange the modal listens -// for. -function renameInEditForm( - modal: AIAssistantProvidersModal, - currentName: string, - nextName: string, -) { - const nameInput = Array.from( - modal.contentEl.querySelectorAll("input"), - ).find((el) => el.value === currentName); - if (!nameInput) throw new Error(`Name input for "${currentName}" not found`); - nameInput.value = nextName; - nameInput.dispatchEvent(new Event("input", { bubbles: true })); -} - -// Select a SecretStorage entry in the edit form. The API-key SecretComponent is -// the only empty-valued text input in edit mode (Name/Endpoint are non-empty, -// the auto-sync toggle is a checkbox). -function setApiKeyRefInEditForm( - modal: AIAssistantProvidersModal, - value: string, -) { - const secretInput = Array.from( - modal.contentEl.querySelectorAll("input"), - ).find((el) => el.type === "text" && el.value === ""); - if (!secretInput) throw new Error("API Key (secret) input not found"); - secretInput.value = value; - secretInput.dispatchEvent(new Event("input", { bubbles: true })); -} - -// Finding: ai-assistant-providers-cancel-discard — Cancel/Escape while editing a -// provider must discard the in-progress edits (restoring the snapshot taken on -// Edit), not persist them. Earlier code left the mutated provider in the array. -describe("AIAssistantProvidersModal discards edits on cancel/dismiss", () => { - beforeAll(() => { - const modalProto = Object.getPrototypeOf( - AIAssistantProvidersModal.prototype, - ) as { onClose?: () => void }; - modalProto.onClose ??= function onClose() {}; - - const btnProto = ButtonComponent.prototype as unknown as { - setDestructive?: () => unknown; - setIcon?: () => unknown; - }; - btnProto.setDestructive ??= function setDestructive(this: unknown) { - return this; - }; - btnProto.setIcon ??= function setIcon(this: unknown) { - return this; - }; - }); - - afterEach(() => { - document.body.empty?.(); - document.body.innerHTML = ""; - }); - - it("restores the original provider when Cancel is clicked", () => { - const providers = [provider()]; - const modal = openProviderEdit(providers); - - renameInEditForm(modal, "Custom", "Renamed"); - expect(providers[0].name).toBe("Renamed"); // edit is live until cancelled - - clickButtonByText(modal, "Cancel"); - - expect(providers[0].name).toBe("Custom"); - }); - - it("restores the original provider when the modal is dismissed (Escape/X)", () => { - const providers = [provider()]; - const modal = openProviderEdit(providers); - - renameInEditForm(modal, "Custom", "Renamed"); - expect(providers[0].name).toBe("Renamed"); - - // close() runs onClose — the Escape/X dismissal path. - modal.close(); - - expect(providers[0].name).toBe("Custom"); - }); - - it("keeps the edit when Save is clicked", () => { - const providers = [provider()]; - const modal = openProviderEdit(providers); - - renameInEditForm(modal, "Custom", "Renamed"); - clickButtonByText(modal, "Save"); - - expect(providers[0].name).toBe("Renamed"); - }); - - it("discards an apiKeyRef added during edit on Cancel (a key the snapshot lacked)", () => { - const providers = [providerWithoutApiKeyRef()]; - const modal = openProviderEdit(providers); - - setApiKeyRefInEditForm(modal, "secret-ref-123"); - expect(providers[0].apiKeyRef).toBe("secret-ref-123"); // live during edit - - clickButtonByText(modal, "Cancel"); - - // Object.assign(provider, snapshot) could not remove apiKeyRef because the - // snapshot never had it; the snapshot-restore must. - expect(providers[0].apiKeyRef).toBeUndefined(); - expect("apiKeyRef" in providers[0]).toBe(false); - }); - - it("discards an apiKeyRef added during edit on dismiss (Escape/X)", () => { - const providers = [providerWithoutApiKeyRef()]; - const modal = openProviderEdit(providers); - - setApiKeyRefInEditForm(modal, "secret-ref-123"); - modal.close(); - - expect(providers[0].apiKeyRef).toBeUndefined(); - }); - - it("discards nested model edits on Cancel", () => { - const providers = [providerWithModels()]; - const modal = openProviderEdit(providers); - - // The Add Model flow mutates selectedProvider.models in place; simulate it. - providers[0].models.push({ name: "leaked", maxTokens: 5 }); - - clickButtonByText(modal, "Cancel"); - - expect(providers[0].models).toEqual([{ name: "m1", maxTokens: 1000 }]); - }); -}); diff --git a/src/gui/AIAssistantProvidersModal.sync.test.ts b/src/gui/AIAssistantProvidersModal.sync.test.ts deleted file mode 100644 index c4f739cf4..000000000 --- a/src/gui/AIAssistantProvidersModal.sync.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { App, ButtonComponent, Notice } from "obsidian"; -import type { AIProvider, Model } from "src/ai/Provider"; -import { settingsStore } from "src/settingsStore"; - -// Each discovery call gets its own deferred so a test can decide which request -// (the quiet on-open sync or "Sync now") lands first. -const discovery = vi.hoisted(() => { - const calls: Array<{ resolve: (models: Model[]) => void }> = []; - return { - calls, - discoverProviderModels: () => - new Promise((resolve) => calls.push({ resolve })), - }; -}); - -vi.mock("src/ai/modelDiscoveryService", () => ({ - discoverProviderModels: discovery.discoverProviderModels, -})); -vi.mock("./GenericInputPrompt/GenericInputPrompt", () => ({ - default: { Prompt: vi.fn() }, -})); -vi.mock("./GenericYesNoPrompt/GenericYesNoPrompt", () => ({ - default: { Prompt: vi.fn().mockResolvedValue(true) }, -})); - -import { AIAssistantProvidersModal } from "./AIAssistantProvidersModal"; - -const SHIPPED: Model[] = [ - { name: "gpt-5.5", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: false }, -]; -const DIRECTORY: Model[] = [ - ...SHIPPED, - { name: "gpt-6-sol", maxTokens: 1_050_000, maxOutputTokens: 128_000, supportsTemperature: false }, -]; - -function openAIProvider(): AIProvider { - return { - id: "openai", - name: "OpenAI", - endpoint: "https://api.openai.com/v1", - apiKey: "", - models: SHIPPED.map((model) => ({ ...model })), - autoSyncModels: true, - modelSource: "modelsDev", - }; -} - -function clickButtonByText(modal: AIAssistantProvidersModal, text: string) { - const button = Array.from( - modal.contentEl.querySelectorAll("button"), - ).find((candidate) => candidate.textContent === text); - if (!button) throw new Error(`Button "${text}" not found`); - button.click(); -} - -// The stub Setting renders settingEl > infoEl > nameEl without Obsidian's -// classes, so read each model row's name structurally. -function shownModelNames(modal: AIAssistantProvidersModal): string[] { - return Array.from( - modal.contentEl.querySelectorAll( - ".models-container > div > div:first-child > div:first-child", - ), - ) - .map((el) => el.textContent ?? "") - .filter((name) => name !== "Add model"); -} - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -/** Resolve the nth discovery request once it has actually been made. */ -async function landDiscovery(index: number, models: Model[]): Promise { - await flush(); - const call = discovery.calls[index]; - if (!call) throw new Error(`Discovery request #${index} was never made`); - call.resolve(models); - await flush(); -} -const notices = () => - (Notice as unknown as { instances: Array<{ message: string }> }).instances.map( - (notice) => notice.message, - ); - -// Regression: the quiet on-open sync used to merge new models into the -// provider being edited without re-rendering, so "Sync now" then revealed -// them while reporting "already up to date" (reproduced in Obsidian 1.13.7). -describe("AIAssistantProvidersModal model sync while editing", () => { - beforeAll(() => { - const modalProto = Object.getPrototypeOf( - AIAssistantProvidersModal.prototype, - ) as { onClose?: () => void }; - modalProto.onClose ??= function onClose() {}; - const btnProto = ButtonComponent.prototype as unknown as { - setDestructive?: () => unknown; - setIcon?: () => unknown; - }; - btnProto.setDestructive ??= function setDestructive(this: unknown) { - return this; - }; - btnProto.setIcon ??= function setIcon(this: unknown) { - return this; - }; - }); - - beforeEach(() => { - discovery.calls.length = 0; - (Notice as unknown as { instances: unknown[] }).instances.length = 0; - settingsStore.setState({ disableOnlineFeatures: false }); - }); - - afterEach(() => { - document.body.innerHTML = ""; - }); - - function openAndEdit(providers: AIProvider[]): AIAssistantProvidersModal { - const modal = new AIAssistantProvidersModal(providers, new App() as App); - clickButtonByText(modal, "Edit"); - return modal; - } - - it("shows models the on-open sync adds after the provider was opened", async () => { - const modal = openAndEdit([openAIProvider()]); - expect(shownModelNames(modal)).toEqual(["gpt-5.5"]); - - await landDiscovery(0, DIRECTORY); - - expect(shownModelNames(modal)).toEqual(["gpt-5.5", "gpt-6-sol"]); - }); - - it("says 'already up to date' only when the list on screen really is", async () => { - const modal = openAndEdit([openAIProvider()]); - await landDiscovery(0, DIRECTORY); - - clickButtonByText(modal, "Sync now"); - await landDiscovery(1, DIRECTORY); - - expect(notices()).toEqual([ - "Synced from the models.dev directory: already up to date.", - ]); - }); - - it("counts models the on-open sync lands after Sync now was clicked", async () => { - const modal = openAndEdit([openAIProvider()]); - - // Click while the on-open sync is still in flight; it then lands first. - clickButtonByText(modal, "Sync now"); - await landDiscovery(0, DIRECTORY); - await landDiscovery(1, DIRECTORY); - - expect(notices()).toEqual([ - "Synced from the models.dev directory: 1 new model(s), 0 updated.", - ]); - expect(shownModelNames(modal)).toEqual(["gpt-5.5", "gpt-6-sol"]); - }); - - it("does not count a model the user adds while Sync now waits", async () => { - const providers = [openAIProvider()]; - const modal = openAndEdit(providers); - - clickButtonByText(modal, "Sync now"); - providers[0].models.push({ name: "my-local-model", maxTokens: 8192 }); - await landDiscovery(0, DIRECTORY); - await landDiscovery(1, DIRECTORY); - - expect(notices()).toEqual([ - "Synced from the models.dev directory: 1 new model(s), 0 updated.", - ]); - expect(providers[0].models.map((model) => model.name)).toEqual([ - "gpt-5.5", - "my-local-model", - "gpt-6-sol", - ]); - }); - - it("keeps background-synced models when the user cancels their edits", async () => { - const providers = [openAIProvider()]; - const modal = openAndEdit(providers); - providers[0].name = "Renamed by user"; - - await landDiscovery(0, DIRECTORY); - clickButtonByText(modal, "Cancel"); - - expect(providers[0].name).toBe("OpenAI"); - expect(providers[0].models.map((model) => model.name)).toEqual([ - "gpt-5.5", - "gpt-6-sol", - ]); - }); - - it("stays quiet when the user cancels while Sync now runs, and keeps what it found", async () => { - const providers = [openAIProvider()]; - const modal = openAndEdit(providers); - await landDiscovery(0, SHIPPED); - - clickButtonByText(modal, "Sync now"); - clickButtonByText(modal, "Cancel"); - await landDiscovery(1, DIRECTORY); - - // No list to report against after Cancel, but the restored snapshot - // must not lose what the sync found. - expect(notices()).toEqual([]); - expect(providers[0].models.map((model) => model.name)).toEqual([ - "gpt-5.5", - "gpt-6-sol", - ]); - }); - - it("lands a background sync on the latest snapshot after repeated Edit/Cancel", async () => { - const providers = [openAIProvider()]; - const modal = openAndEdit(providers); - clickButtonByText(modal, "Cancel"); - clickButtonByText(modal, "Edit"); - clickButtonByText(modal, "Cancel"); - - await landDiscovery(0, DIRECTORY); - - expect(providers[0].models.map((model) => model.name)).toEqual([ - "gpt-5.5", - "gpt-6-sol", - ]); - }); - - it("announces each model once when Sync now is clicked twice", async () => { - const modal = openAndEdit([openAIProvider()]); - await landDiscovery(0, SHIPPED); - - clickButtonByText(modal, "Sync now"); - clickButtonByText(modal, "Sync now"); - await landDiscovery(1, DIRECTORY); - discovery.calls[2]?.resolve(DIRECTORY); - await flush(); - - expect(notices()).toEqual([ - "Synced from the models.dev directory: 1 new model(s), 0 updated.", - ]); - }); - - it("does not wait for another provider's background sync", async () => { - const stalled: AIProvider = { - ...openAIProvider(), - id: "stalled", - name: "Stalled", - endpoint: "https://stalled.example/v1", - }; - const modal = new AIAssistantProvidersModal( - [stalled, openAIProvider()], - new App() as App, - ); - Array.from(modal.contentEl.querySelectorAll("button")) - .filter((button) => button.textContent === "Edit")[1] - .click(); - - // Request #0 is the stalled provider's background sync; it never lands. - clickButtonByText(modal, "Sync now"); - await landDiscovery(1, DIRECTORY); - - expect(notices()).toEqual([ - "Synced from the models.dev directory: 1 new model(s), 0 updated.", - ]); - }); - - it("keeps Sync now results when the user cancels afterwards", async () => { - const providers = [openAIProvider()]; - const modal = openAndEdit(providers); - await landDiscovery(0, SHIPPED); - - clickButtonByText(modal, "Sync now"); - await landDiscovery(1, DIRECTORY); - providers[0].name = "Renamed by user"; - clickButtonByText(modal, "Cancel"); - - expect(providers[0].name).toBe("OpenAI"); - expect(providers[0].models.map((model) => model.name)).toEqual([ - "gpt-5.5", - "gpt-6-sol", - ]); - }); - - it("still syncs when the user saves while Sync now waits", async () => { - const providers = [openAIProvider()]; - const modal = openAndEdit(providers); - - clickButtonByText(modal, "Sync now"); - clickButtonByText(modal, "Save"); - await landDiscovery(0, SHIPPED); - await landDiscovery(1, DIRECTORY); - - expect(notices()).toEqual([ - "Synced from the models.dev directory: 1 new model(s), 0 updated.", - ]); - expect(providers[0].models.map((model) => model.name)).toEqual([ - "gpt-5.5", - "gpt-6-sol", - ]); - }); -}); diff --git a/src/gui/AIAssistantProvidersModal.ts b/src/gui/AIAssistantProvidersModal.ts deleted file mode 100644 index ecc2d0298..000000000 --- a/src/gui/AIAssistantProvidersModal.ts +++ /dev/null @@ -1,680 +0,0 @@ -import { addProviderSecret } from "./ai/providerSettings"; -import type { App } from "obsidian"; -import { ButtonComponent, Modal, Notice, Setting } from "obsidian"; -import type { AIProvider, Model } from "src/ai/Provider"; -import { ensureProviderIds, sortModelsForDisplay } from "src/ai/Provider"; -import { mergeModels, mergeSyncedModels } from "src/ai/modelsDirectory"; -import { diffModelLists, syncProviderModels } from "src/ai/modelSyncService"; -import { - describeConnectionResult, - testProviderConnection, -} from "src/ai/providerConnection"; -import { describeSyncStatus } from "./ai/syncStatus"; -import { settingsStore } from "src/settingsStore"; -import { ModelDirectoryModal } from "./ModelDirectoryModal"; -import { deepClone } from "src/utils/deepClone"; -import GenericInputPrompt from "./GenericInputPrompt/GenericInputPrompt"; -import { ProviderPickerModal } from "./ProviderPickerModal"; -import GenericYesNoPrompt from "./GenericYesNoPrompt/GenericYesNoPrompt"; - -export class AIAssistantProvidersModal extends Modal { - public waitForClose: Promise; - - private resolvePromise: (settings: AIProvider[]) => void; - private rejectPromise: (reason?: unknown) => void; - - private providers: AIProvider[]; - private selectedProvider: AIProvider | null; - - private _selectedProviderClone: AIProvider | null; - - /** The edit view's model list, re-rendered in place when a sync changes it. */ - private modelsContainerEl: HTMLElement | null = null; - - /** The edit view's sync status line under Auto-sync. */ - private syncStatusEl: HTMLElement | null = null; - - /** The edit view's Test connection result line. */ - private connectionResultEl: HTMLElement | null = null; - - /** Filter text for the edit view's model list; kept across re-renders. */ - private modelFilter = ""; - - /** - * Cancel swaps an edited provider for its snapshot. A sync still in flight - * for the discarded object lands its models on the snapshot that replaced - * it instead (see currentFor). - */ - private readonly restoredSnapshots = new WeakMap(); - - constructor(providers: AIProvider[], app: App) { - super(app); - - this.providers = providers; - // Providers from hand-edited data.json may lack the stable id that - // pinned model refs and the qualified script syntax rely on. - ensureProviderIds(this.providers); - - this.waitForClose = new Promise((resolve, reject) => { - this.rejectPromise = reject; - this.resolvePromise = resolve; - }); - - this.open(); - this.display(); - void this.autoSyncOnOpen(); - } - - /** - * Quiet refresh of every auto-sync provider when the settings open, so the - * lists a user is about to browse are current. Failures stay silent here — - * the explicit "Sync now" button is the loud path. - */ - private async autoSyncOnOpen(): Promise { - if (settingsStore.getState().disableOnlineFeatures) return; - - let changed = false; - for (const provider of [...this.providers]) { - if (!provider.autoSyncModels) continue; - try { - const { added, updated, discovered } = await syncProviderModels( - this.app, - provider, - ); - this.applySyncResult(provider, discovered, added + updated > 0); - changed = changed || added > 0; - } catch { - // No Notice by design; the failure shows in the provider's - // sync status line, and "Sync now" is the loud path. - this.applySyncStatus(provider); - } - } - - // Refresh the provider list, but never clobber in-progress edits. - if (changed && !this.selectedProvider) this.reload(); - } - - /** - * The object that stands for `provider` now: itself while it is in the - * list, or the snapshot a Cancel swapped in for it (following repeated - * Edit/Cancel rounds). Null once the provider was deleted. - */ - private currentFor(provider: AIProvider): AIProvider | null { - let current: AIProvider | undefined = provider; - while (current && !this.providers.includes(current)) { - current = this.restoredSnapshots.get(current); - } - return current ?? null; - } - - /** - * Land a finished sync, which syncProviderModels already merged into - * `synced`, on whatever represents that provider now. A sync is not a user - * edit, so an open edit's Cancel snapshot receives it too, and the edit - * view re-renders when it shows that provider. - */ - private applySyncResult( - synced: AIProvider, - discovered: Model[], - syncedChanged: boolean, - ): void { - this.applySyncStatus(synced); - const current = this.currentFor(synced); - if (!current) return; - - let changed = syncedChanged; - if (current !== synced) { - const before = current.models; - current.models = mergeSyncedModels(before, discovered); - const diff = diffModelLists(before, current.models); - changed = diff.added + diff.updated > 0; - } - if (!changed || current !== this.selectedProvider) return; - - if (this._selectedProviderClone) { - this._selectedProviderClone.models = mergeSyncedModels( - this._selectedProviderClone.models, - discovered, - ); - } - this.renderProviderModels(); - } - - /** - * Carry a finished sync's outcome (syncProviderModels records it on the - * object it synced, success or failure) to whatever represents that - * provider now and its Cancel snapshot, and refresh the status line. - */ - private applySyncStatus(synced: AIProvider): void { - const current = this.currentFor(synced); - if (!current) return; - current.lastModelSync = synced.lastModelSync; - if (current !== this.selectedProvider) return; - if (this._selectedProviderClone) { - this._selectedProviderClone.lastModelSync = synced.lastModelSync; - } - this.renderSyncStatus(); - } - - private display(): void { - const modalName = this.selectedProvider - ? `${this.selectedProvider.name}` - : "Providers"; - - this.contentEl.createEl("h2", { - text: modalName, - cls: "qa-modal-title", - }); - - if (this.selectedProvider) { - this.addProviderSetting(this.contentEl); - - return; - } - - this.addProvidersSetting(this.contentEl); - } - - private reload(): void { - this.contentEl.empty(); - this.modelsContainerEl = null; - this.syncStatusEl = null; - this.connectionResultEl = null; - if (!this.selectedProvider) this.modelFilter = ""; - - this.display(); - } - - addProvidersSetting(container: HTMLElement) { - new Setting(container) - .setName("Providers") - .setDesc("Providers for the AI Assistant") - .addButton((button) => { - button.setButtonText("Add provider").onClick(async () => { - await new ProviderPickerModal(this.app, this.providers).waitForClose; - this.reload(); - }); - - button.setCta(); - }); - - const providersContainer = container.createDiv({ - cls: "providers-container qa-ai-list-container", - }); - - this.providers.forEach((provider, i) => { - new Setting(providersContainer) - .setName(provider.name) - .setDesc(provider.endpoint) - .addButton((button) => this.addDeleteButton(button, provider.name, () => this.providers.splice(i, 1))) - .addButton((button) => { - button.setButtonText("Edit").onClick(() => { - this.selectedProvider = provider; - this._selectedProviderClone = deepClone(provider); - - this.reload(); - }); - }); - }); - } - - private addDeleteButton( - button: ButtonComponent, - name: string, - remove: () => void, - ): void { - button.setDestructive(); - button.setIcon("trash"); - button.onClick(async () => { - const confirmed = await GenericYesNoPrompt.Prompt( - this.app, `Are you sure you want to delete ${name}?`, - ); - if (!confirmed) return; - remove(); - this.reload(); - }); - } - - addProviderSetting(container: HTMLElement) { - this.addNameSetting(container); - this.addEndpointSetting(container); - this.addApiKeySetting(container); - this.addTestConnectionSetting(container); - this.addKindSetting(container); - this.addModelSourceSetting(container); - - this.addProviderModelsSetting(container); - this.addImportModelsFromDirectorySetting(container); - this.addAutoSyncSetting(container); - - this.addProviderSettingButtonRow(this.contentEl); - } - - addNameSetting(container: HTMLElement) { - const providerId = this.selectedProvider!.id; - new Setting(container) - .setName("Name") - .setDesc( - providerId - ? `The display name of the provider. Its stable ID is "${providerId}" — use that to qualify models in scripts, e.g. ai.prompt with "${providerId}/model-name".` - : "The display name of the provider", - ) - .addText((text) => { - text.setValue(this.selectedProvider!.name).onChange((value) => { - this.selectedProvider!.name = value; - }); - }); - } - - addEndpointSetting(container: HTMLElement) { - new Setting(container) - .setName("Endpoint") - .setDesc("The endpoint for the AI Assistant") - .addText((text) => { - text.setValue(this.selectedProvider!.endpoint).onChange( - (value) => { - this.selectedProvider!.endpoint = value; - this.invalidateConnectionState(); - } - ); - }); - } - - addApiKeySetting(container: HTMLElement) { - const hasLegacyKey = - !!this.selectedProvider?.apiKey && !this.selectedProvider?.apiKeyRef; - addProviderSecret(container, this.app, { - value: this.selectedProvider?.apiKeyRef ?? "", - hasLegacyKey, - onChange: (value) => { - if (!this.selectedProvider) return; - this.selectedProvider.apiKeyRef = value; - this.selectedProvider.apiKey = ""; - this.invalidateConnectionState(); - }, - }); - } - - addTestConnectionSetting(container: HTMLElement) { - const setting = new Setting(container) - .setName("Connection") - .setDesc("Check that QuickAdd can reach this provider's models endpoint with the linked key."); - const resultEl = setting.descEl.createDiv({ cls: "qa-ai-connection-result" }); - this.connectionResultEl = resultEl; - setting.addButton((button) => { - button.setButtonText("Test connection").onClick(async () => { - const provider = this.selectedProvider; - if (!provider) return; - button.setDisabled(true); - resultEl.setText("Testing…"); - try { - const result = await testProviderConnection(this.app, provider); - resultEl.setText(describeConnectionResult(result)); - } finally { - button.setDisabled(false); - } - }); - }); - } - - /** - * The endpoint, key, wire type, or model source changed: the last sync - * and connection results describe a configuration that no longer exists. - */ - private invalidateConnectionState(): void { - if (!this.selectedProvider) return; - delete this.selectedProvider.lastModelSync; - this.renderSyncStatus(); - this.connectionResultEl?.setText(""); - } - - private renderSyncStatus(): void { - const el = this.syncStatusEl; - const provider = this.selectedProvider; - if (!el || !provider) return; - el.setText(describeSyncStatus(provider, Date.now())); - } - - addKindSetting(container: HTMLElement) { - new Setting(container) - .setName("Provider type") - .setDesc( - "The request format this provider expects. Auto-detect recognizes the official Anthropic and Gemini endpoints and treats everything else as OpenAI-compatible; pick a type explicitly for a proxy or custom endpoint.", - ) - .addDropdown((dropdown) => { - dropdown.addOption("", "Auto-detect"); - dropdown.addOption("openai", "OpenAI-compatible"); - dropdown.addOption("anthropic", "Anthropic"); - dropdown.addOption("gemini", "Gemini"); - dropdown.setValue(this.selectedProvider?.kind ?? ""); - dropdown.onChange((value) => { - if (!this.selectedProvider) return; - this.selectedProvider.kind = value - ? (value as AIProvider["kind"]) - : undefined; - this.invalidateConnectionState(); - }); - }); - } - - addModelSourceSetting(container: HTMLElement) { - const provider = this.selectedProvider; - new Setting(container) - .setName("Model source") - .setDesc( - "Choose where QuickAdd looks when browsing or syncing models for this provider.", - ) - .addDropdown((dropdown) => { - dropdown.addOption( - "providerApi", - "Provider models endpoint (requires API key)", - ); - dropdown.addOption("modelsDev", "models.dev directory"); - dropdown.addOption( - "auto", - "Automatic (try provider, fallback to models.dev)", - ); - const current = provider?.modelSource ?? "providerApi"; - dropdown.setValue(current); - dropdown.onChange((value) => { - if (!this.selectedProvider) return; - this.selectedProvider.modelSource = value as AIProvider["modelSource"]; - delete this.selectedProvider.lastModelSync; - this.reload(); - }); - }); - } - - addProviderModelsSetting(container: HTMLElement) { - new Setting(container) - .setName("Models") - .setDesc("Newest first. Models the provider has retired are listed last.") - .addSearch((search) => { - search - .setPlaceholder("Filter models") - .setValue(this.modelFilter) - .onChange((value) => { - this.modelFilter = value; - this.renderProviderModels(); - }); - }); - this.modelsContainerEl = container.createDiv({ - cls: "models-container qa-ai-list-container", - }); - this.renderProviderModels(); - } - - /** (Re)render the selected provider's model list and its "Add model" row. */ - private renderProviderModels(): void { - const modelsContainer = this.modelsContainerEl; - const provider = this.selectedProvider; - if (!modelsContainer || !provider) return; - modelsContainer.empty(); - - const retired = provider.models.filter((model) => model.deprecated); - if (retired.length > 0) { - // Capture the names shown in the confirm prompt so an in-flight - // sync that marks more models deprecated cannot expand the set. - const retiredNames = new Set(retired.map((model) => model.name)); - new Setting(modelsContainer) - .setName(`${retired.length} retired model(s)`) - .setDesc( - "The provider has deprecated these models, so requests to them may start failing. Commands that use them will need another model.", - ) - .addButton((button) => { - button - .setButtonText(`Remove retired models (${retired.length})`) - .onClick(async () => { - const confirmed = await GenericYesNoPrompt.Prompt( - this.app, - `Remove ${retired.length} retired model(s) from ${provider.name}?`, - ); - if (!confirmed) return; - provider.models = provider.models.filter( - (model) => !retiredNames.has(model.name), - ); - this.renderProviderModels(); - }); - }); - } - - const query = this.modelFilter.trim().toLowerCase(); - const shown = sortModelsForDisplay(provider.models).filter( - (model) => !query || model.name.toLowerCase().includes(query), - ); - if (query && shown.length === 0) { - modelsContainer.createDiv({ - cls: "setting-item-description", - text: `No models match "${this.modelFilter.trim()}".`, - }); - } - shown.forEach((model) => { - const metadata = [`Context: ${model.maxTokens.toLocaleString()} tokens`]; - if (model.maxOutputTokens) { - metadata.push(`Output: ${model.maxOutputTokens.toLocaleString()} tokens`); - } - if (model.supportsTemperature === false) { - metadata.push("Fixed sampling (no temperature)"); - } - if (model.releaseDate) metadata.push(`Released ${model.releaseDate}`); - if (model.deprecated) metadata.push("Retired by the provider"); - new Setting(modelsContainer) - .setName(model.name) - .setDesc(metadata.join(" · ")) - .addButton((button) => - this.addDeleteButton(button, model.name, () => { - // Find by name: a concurrent sync replaces model - // objects, so the closed-over reference may be gone. - const index = provider.models.findIndex( - (entry) => entry.name === model.name, - ); - if (index !== -1) provider.models.splice(index, 1); - }), - ); - }); - - new Setting(modelsContainer) - .setName("Add model") - .addButton((button) => { - button.setButtonText("Add model").onClick(async () => { - let modelName: string; - let maxTokens: string; - try { - modelName = await GenericInputPrompt.Prompt( - this.app, - "Model name" - ); - maxTokens = await GenericInputPrompt.Prompt( - this.app, - "Max tokens" - ); - } catch { - // Cancelling either prompt is a clean no-op. - return; - } - - const trimmedName = modelName.trim(); - if (!trimmedName) { - new Notice("Model name cannot be empty."); - return; - } - - // Reject non-numeric input outright: parseInt would silently - // accept "10abc" as 10. Require a plain positive integer. - const normalizedMaxTokens = maxTokens.trim(); - if (!/^[1-9]\d*$/.test(normalizedMaxTokens)) { - new Notice("Max tokens must be a positive number."); - return; - } - const parsedMaxTokens = Number(normalizedMaxTokens); - - this.selectedProvider!.models.push({ - name: trimmedName, - maxTokens: parsedMaxTokens, - }); - - this.reload(); - }); - button.setCta(); - }); - } - - addImportModelsFromDirectorySetting(container: HTMLElement) { - const sourceDescription = this.describeModelSource(this.selectedProvider); - new Setting(container) - .setName("Import models") - .setDesc(`Browse and import models from ${sourceDescription}.`) - .addButton((button) => { - button.setButtonText("Browse models").onClick(async () => { - const res = await new ModelDirectoryModal(this.app, this.selectedProvider!).waitForClose; - if (!res) return; - const { imported, mode } = res; - if (mode === "replace") { - this.selectedProvider!.models = imported; - } else { - // Merge (not append-only dedupe): re-importing a model the - // provider already has refreshes its context/output metadata. - this.selectedProvider!.models = mergeModels( - this.selectedProvider!.models, - imported - ); - } - new Notice(`Imported ${imported.length} models${mode === "replace" ? " (replaced)" : " (added)"}.`); - this.reload(); - }); - button.setCta(); - }); - } - - addAutoSyncSetting(container: HTMLElement) { - const sourceDescription = this.describeModelSource(this.selectedProvider); - const autoSync = new Setting(container) - .setName("Auto-sync models") - .setDesc( - `Keep this provider's models current automatically: QuickAdd imports new models and refreshed context limits from ${sourceDescription} once a day and when these settings open.`, - ); - this.syncStatusEl = autoSync.descEl.createDiv({ cls: "qa-ai-sync-status" }); - this.renderSyncStatus(); - autoSync - .addToggle((toggle) => { - const current = !!this.selectedProvider?.autoSyncModels; - toggle.setValue(current).onChange((value) => { - if (this.selectedProvider) this.selectedProvider.autoSyncModels = value; - }); - }) - .addButton((button) => { - button.setButtonText("Sync now").onClick(async () => { - const provider = this.selectedProvider!; - // Report against the list the user is looking at. The quiet - // on-open sync may land on this provider while this request - // runs; it is counted too, so the notice never says "up to - // date" while the list visibly changes. - const shown = provider.models.map((model) => ({ ...model })); - button.setDisabled(true); - try { - const { added, updated, discovered } = await syncProviderModels( - this.app, - provider, - ); - this.applySyncResult(provider, discovered, added + updated > 0); - // Cancel swapped this provider for its snapshot while the - // request ran. The snapshot has the models now, but the list - // this click was about is gone, so there is nothing to report. - if (!this.providers.includes(provider)) return; - - // Count only models the source reports, so a model the user - // added by hand meanwhile is not announced as synced. - const sourceNames = new Set(discovered.map((m) => m.name)); - const counts = diffModelLists( - shown, - provider.models.filter((m) => sourceNames.has(m.name)), - ); - new Notice( - counts.added > 0 || counts.updated > 0 - ? `Synced from ${sourceDescription}: ${counts.added} new model(s), ${counts.updated} updated.` - : `Synced from ${sourceDescription}: already up to date.`, - ); - this.reload(); - } catch (err) { - this.applySyncStatus(provider); - new Notice( - `Sync failed: ${(err as { message?: string }).message ?? err}` - ); - } finally { - button.setDisabled(false); - } - }); - button.setCta(); - }); - } - - // Discard in-progress edits to the selected provider by restoring the - // snapshot taken on Edit. We swap the array entry wholesale rather than - // Object.assign-ing the clone over the live object: Object.assign cannot - // remove keys the edit ADDED but the snapshot lacks (e.g. an apiKeyRef set on - // a default provider that had none), so those edits would survive Cancel. - private restoreSelectedProviderFromClone(): void { - if (!this.selectedProvider || !this._selectedProviderClone) return; - - const index = this.providers.indexOf(this.selectedProvider); - if (index !== -1) { - this.providers[index] = this._selectedProviderClone; - this.restoredSnapshots.set( - this.selectedProvider, - this._selectedProviderClone, - ); - } - - this.selectedProvider = null; - this._selectedProviderClone = null; - } - - addProviderSettingButtonRow(container: HTMLElement) { - const buttonRow = container.createDiv({ - cls: "button-row qa-ai-provider-button-row", - }); - - const CancelButton = new ButtonComponent(buttonRow); - CancelButton.setButtonText("Cancel"); - CancelButton.setDestructive(); - CancelButton.onClick(() => { - // Cancel always returns to the provider list, discarding edits. We - // never close() here so the modal doesn't flash-close-and-reopen via - // onClose's path. - this.restoreSelectedProviderFromClone(); - - this.reload(); - }); - - const SaveButton = new ButtonComponent(buttonRow); - SaveButton.setButtonText("Save"); - SaveButton.setCta(); - SaveButton.onClick(() => { - this.selectedProvider = null; - this.reload(); - }); - } - - describeModelSource(provider: AIProvider | null): string { - const mode = provider?.modelSource ?? "providerApi"; - switch (mode) { - case "modelsDev": - return "the models.dev directory"; - case "auto": - return "the provider's models endpoint (falls back to models.dev)"; - default: - return "the provider's models endpoint"; - } - } - - onClose(): void { - // If the user dismissed while editing a provider (Escape / X), discard - // the in-progress edits by restoring the snapshot, then resolve and close. - // We do NOT reopen the modal here — reopening on close made Escape re-show - // the dialog and required a second Escape to actually leave. - this.restoreSelectedProviderFromClone(); - - this.resolvePromise(this.providers); - super.onClose(); - } -} diff --git a/src/gui/AIAssistantProvidersModal.ux.test.ts b/src/gui/AIAssistantProvidersModal.ux.test.ts deleted file mode 100644 index e886ec455..000000000 --- a/src/gui/AIAssistantProvidersModal.ux.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { App, ButtonComponent } from "obsidian"; -import type { AIProvider, Model } from "src/ai/Provider"; -import { settingsStore } from "src/settingsStore"; - -const discovery = vi.hoisted(() => ({ - discoverProviderModels: vi.fn(), -})); - -vi.mock("src/ai/modelDiscoveryService", () => ({ - discoverProviderModels: discovery.discoverProviderModels, -})); -vi.mock("./GenericInputPrompt/GenericInputPrompt", () => ({ - default: { Prompt: vi.fn() }, -})); -vi.mock("./GenericYesNoPrompt/GenericYesNoPrompt", () => ({ - default: { Prompt: vi.fn().mockResolvedValue(true) }, -})); - -import { AIAssistantProvidersModal } from "./AIAssistantProvidersModal"; - -function provider(models: Model[], overrides: Partial = {}): AIProvider { - return { - id: "openai", - name: "OpenAI", - endpoint: "https://api.openai.com/v1", - apiKey: "", - models, - autoSyncModels: false, - modelSource: "modelsDev", - ...overrides, - }; -} - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -function buttons(modal: AIAssistantProvidersModal): HTMLButtonElement[] { - return Array.from(modal.contentEl.querySelectorAll("button")); -} - -function click(modal: AIAssistantProvidersModal, text: string) { - const button = buttons(modal).find((b) => b.textContent === text); - if (!button) throw new Error(`Button "${text}" not found`); - button.click(); -} - -// Stub Setting: settingEl > [infoEl > [nameEl, descEl], controlEl]. -function modelRows(modal: AIAssistantProvidersModal): HTMLElement[] { - return Array.from( - modal.contentEl.querySelectorAll(".models-container > div"), - ).filter((row) => row.firstElementChild?.firstElementChild); -} - -function shownModelNames(modal: AIAssistantProvidersModal): string[] { - return modelRows(modal) - .map((row) => row.firstElementChild!.firstElementChild!.textContent ?? "") - .filter((name) => name !== "Add model" && !name.includes("retired model")); -} - -function settingDesc(modal: AIAssistantProvidersModal, name: string): string { - const nameEl = Array.from(modal.contentEl.querySelectorAll("div")).find( - (el) => el.textContent === name && el.parentElement?.parentElement, - ); - return nameEl?.nextElementSibling?.textContent ?? ""; -} - -function openEdit(providers: AIProvider[]): AIAssistantProvidersModal { - const modal = new AIAssistantProvidersModal(providers, new App() as App); - click(modal, "Edit"); - return modal; -} - -const GPT4O: Model = { name: "gpt-4o", maxTokens: 128_000, releaseDate: "2024-05-13" }; -const GPT6: Model = { name: "gpt-6-sol", maxTokens: 1_050_000, releaseDate: "2026-09-22" }; -const O4MINI: Model = { name: "o4-mini", maxTokens: 200_000, releaseDate: "2025-04-16", deprecated: true }; - -describe("AIAssistantProvidersModal model list and connection UX", () => { - beforeAll(() => { - const modalProto = Object.getPrototypeOf( - AIAssistantProvidersModal.prototype, - ) as { onClose?: () => void }; - modalProto.onClose ??= function onClose() {}; - const btnProto = ButtonComponent.prototype as unknown as { - setDestructive?: () => unknown; - setIcon?: () => unknown; - }; - btnProto.setDestructive ??= function setDestructive(this: unknown) { - return this; - }; - btnProto.setIcon ??= function setIcon(this: unknown) { - return this; - }; - }); - - beforeEach(() => { - discovery.discoverProviderModels.mockReset(); - settingsStore.setState({ disableOnlineFeatures: false }); - }); - - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("lists models newest first with retired ones last, without reordering storage", () => { - const providers = [provider([GPT4O, O4MINI, GPT6])]; - const modal = openEdit(providers); - - expect(shownModelNames(modal)).toEqual(["gpt-6-sol", "gpt-4o", "o4-mini"]); - expect(providers[0].models.map((m) => m.name)).toEqual(["gpt-4o", "o4-mini", "gpt-6-sol"]); - }); - - it("deletes the model whose row was clicked, not the one at that stored index", async () => { - const providers = [provider([GPT4O, GPT6])]; - const modal = openEdit(providers); - - // Row 0 on screen is gpt-6-sol, but stored index 0 is gpt-4o. - modelRows(modal)[0].querySelector("button")!.click(); - await flush(); - - expect(providers[0].models.map((m) => m.name)).toEqual(["gpt-4o"]); - }); - - it("filters the list by name", () => { - const modal = openEdit([provider([GPT4O, O4MINI, GPT6])]); - const filter = modal.contentEl.querySelector('input[type="search"]')!; - - filter.value = "GPT"; - filter.dispatchEvent(new Event("input")); - - expect(shownModelNames(modal)).toEqual(["gpt-6-sol", "gpt-4o"]); - }); - - it("removes only retired models on request, and Cancel brings them back", async () => { - const providers = [provider([GPT4O, O4MINI, GPT6])]; - const modal = openEdit(providers); - - click(modal, "Remove retired models (1)"); - await flush(); - - expect(providers[0].models.map((m) => m.name)).toEqual(["gpt-4o", "gpt-6-sol"]); - expect(buttons(modal).some((b) => b.textContent?.startsWith("Remove retired"))).toBe(false); - - click(modal, "Cancel"); - expect(providers[0].models.map((m) => m.name)).toEqual(["gpt-4o", "o4-mini", "gpt-6-sol"]); - }); - - it("removes only the retired names confirmed, not ones marked retired while the prompt is open", async () => { - const yesNo = await import("./GenericYesNoPrompt/GenericYesNoPrompt"); - let resolveConfirm!: (value: boolean) => void; - vi.mocked(yesNo.default.Prompt).mockImplementationOnce( - () => - new Promise((resolve) => { - resolveConfirm = resolve; - }), - ); - - const providers = [provider([GPT4O, O4MINI, GPT6])]; - const modal = openEdit(providers); - click(modal, "Remove retired models (1)"); - await flush(); - - // In-flight sync marks gpt-4o retired while the confirm is open. - providers[0].models = providers[0].models.map((model) => - model.name === "gpt-4o" ? { ...model, deprecated: true } : model, - ); - - resolveConfirm(true); - await flush(); - - expect(providers[0].models.map((m) => m.name)).toEqual(["gpt-4o", "gpt-6-sol"]); - expect(providers[0].models.find((m) => m.name === "gpt-4o")?.deprecated).toBe(true); - }); - - it("deletes by model name after a sync replaces the closed-over object", async () => { - const yesNo = await import("./GenericYesNoPrompt/GenericYesNoPrompt"); - let resolveConfirm!: (value: boolean) => void; - vi.mocked(yesNo.default.Prompt).mockImplementationOnce( - () => - new Promise((resolve) => { - resolveConfirm = resolve; - }), - ); - - const providers = [provider([GPT4O, GPT6])]; - const modal = openEdit(providers); - - // Row 0 is gpt-6-sol (newest first). Click delete, then replace objects. - modelRows(modal)[0].querySelector("button")!.click(); - await flush(); - providers[0].models = providers[0].models.map((model) => ({ ...model })); - - resolveConfirm(true); - await flush(); - - expect(providers[0].models.map((m) => m.name)).toEqual(["gpt-4o"]); - }); - - it("tests the connection against the provider's own endpoint, even for a models.dev provider", async () => { - discovery.discoverProviderModels.mockResolvedValue([GPT4O, GPT6, O4MINI]); - const modal = openEdit([provider([GPT4O])]); - - click(modal, "Test connection"); - await flush(); - - expect(discovery.discoverProviderModels).toHaveBeenCalledWith( - expect.objectContaining({ modelSource: "providerApi" }), - "", - ); - expect(settingDesc(modal, "Connection")).toContain( - "✓ Connected. The provider lists 3 model(s).", - ); - }); - - it("shows the provider's error when the connection test fails", async () => { - discovery.discoverProviderModels.mockRejectedValue( - new Error("OpenAI request failed (HTTP 401): Incorrect API key provided"), - ); - const modal = openEdit([provider([GPT4O])]); - - click(modal, "Test connection"); - await flush(); - - expect(settingDesc(modal, "Connection")).toContain( - "✗ OpenAI request failed (HTTP 401): Incorrect API key provided (No API key is linked.)", - ); - }); - - it("clears a stale sync status and connection result when the endpoint changes", async () => { - discovery.discoverProviderModels.mockResolvedValue([GPT4O]); - const providers = [ - provider([GPT4O], { lastModelSync: { at: Date.now() - 60_000 } }), - ]; - const modal = openEdit(providers); - click(modal, "Test connection"); - await flush(); - expect(settingDesc(modal, "Auto-sync models")).toContain("Last synced 1 minute ago"); - expect(settingDesc(modal, "Connection")).toContain("✓ Connected"); - - const endpoint = Array.from(modal.contentEl.querySelectorAll("input")).find( - (input) => input.value === "https://api.openai.com/v1", - )!; - endpoint.value = "https://proxy.example/v1"; - endpoint.dispatchEvent(new Event("input")); - - expect(settingDesc(modal, "Auto-sync models")).toContain("Not synced yet."); - expect(settingDesc(modal, "Connection")).not.toContain("✓ Connected"); - expect(providers[0].lastModelSync).toBeUndefined(); - - // Cancel restores the saved configuration together with its status. - click(modal, "Cancel"); - expect(providers[0].lastModelSync?.at).toBeLessThan(Date.now()); - }); - - it("updates the sync status line when the background sync lands or fails", async () => { - let fail!: (error: Error) => void; - discovery.discoverProviderModels.mockReturnValue( - new Promise((_resolve, reject) => { - fail = reject; - }), - ); - const modal = openEdit([provider([GPT4O], { autoSyncModels: true })]); - expect(settingDesc(modal, "Auto-sync models")).toContain("Not synced yet."); - - fail(new Error("Request failed, status 503")); - await flush(); - expect(settingDesc(modal, "Auto-sync models")).toContain( - "Last sync failed just now: Request failed, status 503", - ); - - discovery.discoverProviderModels.mockResolvedValue([GPT4O, GPT6]); - click(modal, "Sync now"); - await flush(); - await flush(); - expect(settingDesc(modal, "Auto-sync models")).toContain( - "Last synced just now · 2 model(s).", - ); - }); -}); diff --git a/src/gui/AIAssistantSettingsModal.ts b/src/gui/AIAssistantSettingsModal.ts deleted file mode 100644 index f86cf0c6a..000000000 --- a/src/gui/AIAssistantSettingsModal.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { App } from "obsidian"; -import { Modal, Setting, TextAreaComponent } from "obsidian"; -import type { QuickAddSettings } from "src/settings"; -import { mountSystemPromptLiteralNote } from "./ai/systemPromptLiteralNote"; -import { AIAssistantProvidersModal } from "./AIAssistantProvidersModal"; -import { populateModelDropdown } from "./modelSelect"; -import { GenericTextSuggester } from "./suggesters/genericTextSuggester"; -import { getAllFolderPathsInVault } from "src/utilityObsidian"; - -type AIAssistantSettings = QuickAddSettings["ai"]; - -export class AIAssistantSettingsModal extends Modal { - public waitForClose: Promise; - - private resolvePromise: (settings: AIAssistantSettings) => void; - private rejectPromise: (reason?: unknown) => void; - - private settings: AIAssistantSettings; - - constructor(app: App, settings: AIAssistantSettings) { - super(app); - - this.settings = settings; - - this.waitForClose = new Promise( - (resolve, reject) => { - this.rejectPromise = reject; - this.resolvePromise = resolve; - } - ); - - this.open(); - this.display(); - } - - private display(): void { - this.modalEl.addClass("qa-ai-wide-modal"); - this.contentEl.addClass("qa-ai-scroll-content"); - - this.contentEl.createEl("h2", { - text: "AI Assistant settings", - cls: "qa-modal-title", - }); - - this.addProvidersSetting(this.contentEl); - this.addDefaultModelSetting(this.contentEl); - this.addPromptTemplateFolderPathSetting(this.contentEl); - this.addShowAssistantSetting(this.contentEl); - this.addConfirmToolCallsSetting(this.contentEl); - - this.addDefaultSystemPromptSetting(this.contentEl); - } - - private reload(): void { - this.contentEl.empty(); - - this.display(); - } - - addProvidersSetting(container: HTMLElement) { - new Setting(container) - .setName("Providers") - .setDesc("The providers for the AI Assistant") - .addButton((button) => { - button.setButtonText("Edit providers").onClick(() => { - void new AIAssistantProvidersModal( - this.settings.providers, - this.app - ).waitForClose.then(() => { - this.reload(); - }); - }); - }); - } - - addDefaultModelSetting(container: HTMLElement) { - new Setting(container) - .setName("Default model") - .setDesc("The default model for the AI Assistant") - .addDropdown((dropdown) => { - populateModelDropdown( - dropdown, - { - model: this.settings.defaultModel, - modelRef: this.settings.defaultModelRef, - }, - (selection) => { - this.settings.defaultModel = selection.model; - this.settings.defaultModelRef = selection.modelRef; - }, - ); - }); - } - - addPromptTemplateFolderPathSetting(container: HTMLElement) { - new Setting(container) - .setName("Prompt template folder path") - .setDesc("Path to your folder with prompt templates") - .addText((text) => { - text.setValue(this.settings.promptTemplatesFolderPath).onChange( - (value) => { - this.settings.promptTemplatesFolderPath = value; - } - ); - - new GenericTextSuggester( - this.app, - text.inputEl, - getAllFolderPathsInVault(this.app) - ); - }); - } - - addShowAssistantSetting(container: HTMLElement) { - new Setting(container) - .setName("Show assistant") - .setDesc("Show status messages from the AI Assistant") - .addToggle((toggle) => { - toggle.setValue(this.settings.showAssistant); - toggle.onChange((value) => { - this.settings.showAssistant = value; - }); - }); - } - - addConfirmToolCallsSetting(container: HTMLElement) { - new Setting(container) - .setName("Confirm AI tool calls") - .setDesc( - "When an AI agent runs script-defined or built-in tools, ask before executing. 'Destructive only' confirms tools not marked read-only; 'Always' confirms every tool; 'Never' defers to each tool's own setting. A tool that requires approval is always confirmed regardless.", - ) - .addDropdown((dropdown) => { - dropdown.addOption("destructive", "Destructive tools only (recommended)"); - dropdown.addOption("always", "Always confirm every tool"); - dropdown.addOption("never", "Never (use each tool's own setting)"); - dropdown.setValue(this.settings.confirmToolCalls ?? "destructive"); - dropdown.onChange((value) => { - this.settings.confirmToolCalls = - value as QuickAddSettings["ai"]["confirmToolCalls"]; - }); - }); - } - - addDefaultSystemPromptSetting(contentEl: HTMLElement) { - new Setting(contentEl) - .setName("Default system prompt") - .setDesc("The default system prompt for the AI Assistant"); - - const textAreaComponent = new TextAreaComponent(contentEl); - textAreaComponent.inputEl.addClass("qa-ai-prompt-textarea"); - // The textarea is appended to contentEl rather than to the Setting's - // controlEl (it needs the full modal width), so nothing associates it with - // the "Default system prompt" name above. - textAreaComponent.inputEl.setAttribute( - "aria-label", - "Default system prompt", - ); - - // No format preview and no `{{` token autocomplete here: the system prompt - // is sent to the model verbatim (see mountSystemPromptLiteralNote). The - // preview this replaces resolved the tokens on screen and was, for the - // shipped token-free default, a character-for-character duplicate of the - // textarea above it (#1568). - const updateLiteralNote = mountSystemPromptLiteralNote( - contentEl, - textAreaComponent.inputEl, - this.settings.defaultSystemPrompt ?? "", - ); - - textAreaComponent - .setValue(this.settings.defaultSystemPrompt) - .onChange((value) => { - this.settings.defaultSystemPrompt = value; - updateLiteralNote(value); - }); - } - - onClose(): void { - this.resolvePromise(this.settings); - super.onClose(); - } -} diff --git a/src/gui/ProviderPickerModal.ts b/src/gui/ProviderPickerModal.ts index 4c5db2f0f..6f3aa2ac9 100644 --- a/src/gui/ProviderPickerModal.ts +++ b/src/gui/ProviderPickerModal.ts @@ -154,7 +154,7 @@ export class ProviderPickerModal extends Modal { b.setButtonText("Add custom...").onClick(() => { const provider: AIProvider = { id: uniqueProviderId("custom", this.providers), name: "Custom", endpoint: "", apiKey: "", apiKeyRef: "", models: [], modelSource: "providerApi" }; this.providers.push(provider); - new Notice("Custom provider added. Click Edit to configure."); + new Notice("Custom provider added. Open it in the list to configure it."); this.close(); }); }); diff --git a/src/gui/ai/AIProviderSettingPage.test.ts b/src/gui/ai/AIProviderSettingPage.test.ts new file mode 100644 index 000000000..762cc2f38 --- /dev/null +++ b/src/gui/ai/AIProviderSettingPage.test.ts @@ -0,0 +1,290 @@ +import { App, Notice } from "obsidian"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AIProvider, Model } from "src/ai/Provider"; +import { DEFAULT_SETTINGS } from "src/settings"; +import { settingsStore } from "src/settingsStore"; +import { deepClone } from "src/utils/deepClone"; + +const mocks = vi.hoisted(() => ({ + discover: vi.fn(), + input: vi.fn(), + confirm: vi.fn(), +})); +vi.mock("src/ai/modelDiscoveryService", () => ({ discoverProviderModels: mocks.discover })); +vi.mock("../GenericInputPrompt/GenericInputPrompt", () => ({ default: { Prompt: mocks.input } })); +vi.mock("../GenericYesNoPrompt/GenericYesNoPrompt", () => ({ default: { Prompt: mocks.confirm } })); + +import { AIProviderSettingPage } from "./AIProviderSettingPage"; + +const OLD: Model = { name: "old", maxTokens: 10, releaseDate: "2020-01-01" }; +const NEW: Model = { name: "new", maxTokens: 20, releaseDate: "2025-01-01" }; +const RETIRED: Model = { name: "retired", maxTokens: 30, releaseDate: "2026-01-01", deprecated: true }; + +function provider(id = "target", models: Model[] = [OLD]): AIProvider { + return { id, name: id, endpoint: `https://${id}.example/v1`, apiKey: "", modelSource: "modelsDev", autoSyncModels: false, models }; +} + +function install(...providers: AIProvider[]): void { + settingsStore.setState((state) => ({ ai: { ...state.ai, providers } })); +} + +function open(id = "target"): AIProviderSettingPage { + const page = new AIProviderSettingPage(new App(), id); + page.display(); + return page; +} + +function button(page: AIProviderSettingPage, text: string): HTMLElement { + const found = Array.from(page.containerEl.querySelectorAll("button, [aria-label]")) + .find((el) => el.textContent === text || el.getAttribute("aria-label") === text); + if (!found) throw new Error(`Missing button ${text}`); + return found; +} + +function modelNames(page: AIProviderSettingPage): string[] { + const list = page.containerEl.querySelector(".qa-ai-models-group")?.lastElementChild; + return Array.from(list?.children ?? []) + .map((row) => row.firstElementChild?.firstElementChild?.childNodes[0]?.textContent ?? "") + .filter((name) => ["old", "new", "retired", "local", "fresh"].includes(name)); +} + +function setting(page: AIProviderSettingPage, name: string): HTMLElement { + const nameEl = Array.from(page.containerEl.querySelectorAll("div")).find((el) => + el.childNodes.length === 1 && el.textContent === name, + ); + if (!nameEl?.parentElement?.parentElement) throw new Error(`Missing setting ${name}`); + return nameEl.parentElement.parentElement; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); +const notices = () => (Notice as unknown as { instances: Array<{ message: string }> }).instances.map((n) => n.message); + +describe("AIProviderSettingPage", () => { + beforeEach(() => { + settingsStore.replaceState(deepClone(DEFAULT_SETTINGS)); + settingsStore.setState({ disableOnlineFeatures: false }); + mocks.discover.mockReset(); + mocks.input.mockReset(); + mocks.confirm.mockReset().mockResolvedValue(true); + (Notice as unknown as { instances: unknown[] }).instances.length = 0; + }); + + afterEach(() => { document.body.innerHTML = ""; }); + + it("sorts newest-first with retired last without changing storage, and filters", () => { + install(provider("target", [OLD, RETIRED, NEW])); + const page = open(); + expect(modelNames(page)).toEqual(["new", "old", "retired"]); + expect(settingsStore.getState().ai.providers[0].models).toEqual([OLD, RETIRED, NEW]); + + const search = page.containerEl.querySelector('input[type="search"]')!; + search.value = "OLD"; + search.dispatchEvent(new Event("input")); + expect(modelNames(page)).toEqual(["old"]); + }); + + it("deletes the clicked display row by name after model objects are replaced", async () => { + install(provider("target", [OLD, NEW])); + const page = open(); + let resolve!: (answer: boolean) => void; + mocks.confirm.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + const rows = Array.from(page.containerEl.querySelectorAll(".qa-ai-models-group > :last-child > div")); + (rows[0].querySelector('[aria-label="Delete model"]') as HTMLElement).click(); + install({ ...settingsStore.getState().ai.providers[0], models: [OLD, NEW].map((m) => ({ ...m })) }); + resolve(true); + await flush(); + expect(settingsStore.getState().ai.providers[0].models.map((m) => m.name)).toEqual(["old"]); + }); + + it("removes only retired names captured before confirmation", async () => { + install(provider("target", [OLD, RETIRED, NEW])); + const page = open(); + let resolve!: (answer: boolean) => void; + mocks.confirm.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + button(page, "Remove retired").click(); + install({ ...settingsStore.getState().ai.providers[0], models: [ + { ...OLD, deprecated: true }, RETIRED, NEW, + ] }); + resolve(true); + await flush(); + expect(settingsStore.getState().ai.providers[0].models.map((m) => m.name)).toEqual(["old", "new"]); + }); + + it("tests the provider API and renders success and unkeyed failure lines", async () => { + install(provider()); + mocks.discover.mockResolvedValueOnce([OLD, NEW]); + const page = open(); + button(page, "Test connection").click(); + await vi.waitFor(() => expect(setting(page, "Connection").textContent).toContain("✓ Connected. The provider lists 2 models.")); + expect(mocks.discover).toHaveBeenCalledWith(expect.objectContaining({ modelSource: "providerApi" }), ""); + + mocks.discover.mockRejectedValueOnce(new Error("401")); + button(page, "Test connection").click(); + await vi.waitFor(() => expect(setting(page, "Connection").textContent).toContain("✗ 401 (No API key is linked.)")); + }); + + it("endpoint edits clear sync and connection status and target the correct provider", async () => { + install(provider("other"), { ...provider(), lastModelSync: { at: Date.now() } }); + mocks.discover.mockResolvedValue([OLD]); + const page = open(); + button(page, "Test connection").click(); + await vi.waitFor(() => expect(setting(page, "Connection").textContent).toContain("✓")); + const endpoint = setting(page, "Endpoint").querySelector("input")!; + endpoint.value = "https://changed.example"; + endpoint.dispatchEvent(new Event("input")); + expect(settingsStore.getState().ai.providers.map((p) => [p.id, p.endpoint])).toEqual([ + ["other", "https://other.example/v1"], ["target", "https://changed.example"], + ]); + expect(settingsStore.getState().ai.providers[1].lastModelSync).toBeUndefined(); + expect(setting(page, "Connection").textContent).not.toContain("✓"); + }); + + it("name edits update only the addressed provider", () => { + install(provider("other"), provider()); + const page = open(); + const input = setting(page, "Name").querySelector("input")!; + input.value = "Renamed"; + input.dispatchEvent(new Event("input")); + expect(settingsStore.getState().ai.providers.map((p) => p.name)).toEqual(["other", "Renamed"]); + }); + + it("re-renders list and status when on-open sync lands, and records failures", async () => { + install({ ...provider(), autoSyncModels: true }); + mocks.discover.mockResolvedValueOnce([OLD, { name: "fresh", maxTokens: 99 }]); + const page = open(); + await vi.waitFor(() => expect(modelNames(page)).toEqual(["old", "fresh"])); + expect(setting(page, "Auto-sync models").textContent).toContain("Last synced just now"); + + mocks.discover.mockRejectedValueOnce(new Error("service unavailable")); + button(page, "Sync now").click(); + await vi.waitFor(() => expect(setting(page, "Auto-sync models").textContent).toContain("service unavailable")); + }); + + it("Sync now excludes a concurrent local model from its added count and reports up-to-date", async () => { + install(provider()); + let resolve!: (models: Model[]) => void; + mocks.discover.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + const page = open(); + button(page, "Sync now").click(); + install({ ...settingsStore.getState().ai.providers[0], models: [OLD, { name: "local", maxTokens: 5 }] }); + resolve([OLD, NEW]); + await vi.waitFor(() => expect(notices()).toContain("Synced from the models.dev directory: 1 new, 0 updated.")); + + mocks.discover.mockResolvedValueOnce([OLD, NEW]); + button(page, "Sync now").click(); + await vi.waitFor(() => expect(notices().at(-1)).toContain("already up to date")); + }); + + it.each([ + ["blank name", [" ", "10"]], + ["non-numeric tokens", ["model", "wat"]], + ["trailing junk", ["model", "10abc"]], + ["duplicate", ["old", "10"]], + ] as const)("rejects invalid Add model input: %s", async (_label, answers) => { + install(provider()); + mocks.input.mockResolvedValueOnce(answers[0]).mockResolvedValueOnce(answers[1]); + const page = open(); + button(page, "Add model").click(); + await vi.waitFor(() => expect(mocks.input).toHaveBeenCalledTimes(2)); + await flush(); + expect(settingsStore.getState().ai.providers[0].models).toEqual([OLD]); + }); + + it("treats Add model cancellation as a no-op and appends valid input", async () => { + install(provider()); + mocks.input.mockRejectedValueOnce(new Error("cancel")); + const page = open(); + button(page, "Add model").click(); + await flush(); + expect(settingsStore.getState().ai.providers[0].models).toEqual([OLD]); + + mocks.input.mockResolvedValueOnce(" valid ").mockResolvedValueOnce("128000"); + button(page, "Add model").click(); + await vi.waitFor(() => expect(settingsStore.getState().ai.providers[0].models.at(-1)).toEqual({ name: "valid", maxTokens: 128000 })); + }); + + it("hide unsubscribes and removed providers render a terminal message", () => { + install(provider()); + const page = open(); + page.hide(); + page.containerEl.remove(); + expect(() => install({ ...provider(), models: [NEW] })).not.toThrow(); + + install(); + const removed = open(); + expect(removed.containerEl.textContent).toBe("This provider no longer exists."); + }); + + function openWithSettingModal(id = "target") { + const setting = { closePage: vi.fn(), updatePageTitle: vi.fn() }; + const app = Object.assign(new App(), { setting }); + const page = new AIProviderSettingPage(app as unknown as App, id); + page.display(); + return { page, setting }; + } + + // Page entries get no trash button from Obsidian, and Delete/Backspace needs + // a keyboard, so the page itself must offer deletion (phones). + it("deletes the provider from its own page after confirmation, then goes back", async () => { + install(provider("other"), provider()); + const { page, setting } = openWithSettingModal(); + + mocks.confirm.mockResolvedValueOnce(false); + button(page, "Delete").click(); + await flush(); + expect(settingsStore.getState().ai.providers.map((p) => p.id)).toEqual(["other", "target"]); + expect(setting.closePage).not.toHaveBeenCalled(); + + mocks.confirm.mockResolvedValueOnce(true); + button(page, "Delete").click(); + await flush(); + expect(settingsStore.getState().ai.providers.map((p) => p.id)).toEqual(["other"]); + expect(setting.closePage).toHaveBeenCalledTimes(1); + }); + + it("keeps both titles in step with a rename", () => { + install(provider()); + const { page, setting: modal } = openWithSettingModal(); + const titleEl = page.titlebarEl.createDiv({ cls: "setting-page-title", text: "target" }); + const input = setting(page, "Name").querySelector("input")!; + + input.value = " Work "; + input.dispatchEvent(new Event("input")); + expect([page.title, titleEl.textContent]).toEqual(["Work", "Work"]); + expect(modal.updatePageTitle).toHaveBeenCalled(); + + input.value = " "; + input.dispatchEvent(new Event("input")); + expect(page.title).toBe("Untitled provider"); + }); + + it("colors connection and sync results by outcome", async () => { + install(provider()); + const page = open(); + const line = () => setting(page, "Connection").querySelector(".qa-ai-status-line")!; + + mocks.discover.mockResolvedValueOnce([OLD]); + button(page, "Test connection").click(); + await vi.waitFor(() => expect(line().classList.contains("mod-success")).toBe(true)); + expect(line().classList.contains("mod-error")).toBe(false); + + mocks.discover.mockRejectedValueOnce(new Error("401")); + button(page, "Test connection").click(); + await vi.waitFor(() => expect(line().classList.contains("mod-error")).toBe(true)); + expect(line().classList.contains("mod-success")).toBe(false); + + mocks.discover.mockRejectedValueOnce(new Error("503")); + button(page, "Sync now").click(); + await vi.waitFor(() => + expect(setting(page, "Auto-sync models").querySelector(".qa-ai-status-line")!.classList.contains("mod-error")).toBe(true), + ); + }); + + it("does not auto-sync while online features are disabled", async () => { + settingsStore.setState({ disableOnlineFeatures: true }); + install({ ...provider(), autoSyncModels: true }); + open(); + await flush(); + expect(mocks.discover).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gui/ai/AIProviderSettingPage.ts b/src/gui/ai/AIProviderSettingPage.ts new file mode 100644 index 000000000..79e4dbe05 --- /dev/null +++ b/src/gui/ai/AIProviderSettingPage.ts @@ -0,0 +1,541 @@ +import type { App, ButtonComponent } from "obsidian"; +import { Notice, Setting, SettingGroup, SettingPage } from "obsidian"; +import type { AIProvider, Model } from "src/ai/Provider"; +import { sortModelsForDisplay } from "src/ai/Provider"; +import { mergeModels } from "src/ai/modelsDirectory"; +import { diffModelLists, syncStoredProvider } from "src/ai/modelSyncService"; +import { + describeConnectionResult, + testProviderConnection, +} from "src/ai/providerConnection"; +import { settingsStore } from "src/settingsStore"; +import GenericInputPrompt from "../GenericInputPrompt/GenericInputPrompt"; +import GenericYesNoPrompt from "../GenericYesNoPrompt/GenericYesNoPrompt"; +import { ModelDirectoryModal } from "../ModelDirectoryModal"; +import { + findProvider, + removeProvider, + updateProvider, + withoutSyncStatus, +} from "./aiSettingsState"; +import { configureProviderSecret } from "./providerSettings"; +import { countModels, describeSyncStatus } from "./syncStatus"; + +export function describeModelSource(provider: Pick): string { + switch (provider.modelSource ?? "providerApi") { + case "modelsDev": + return "the models.dev directory"; + case "auto": + return "the provider's models endpoint (falls back to models.dev)"; + default: + return "the provider's models endpoint"; + } +} + +/** Set a result line under a setting's description, colored by outcome. */ +function setStatusLine( + el: HTMLElement, + text: string, + tone?: "success" | "error", +): void { + el.setText(text); + el.classList.toggle("mod-success", tone === "success"); + el.classList.toggle("mod-error", tone === "error"); +} + +/** "Context: 128,000 tokens · Output: … · Released 2024-05-13". */ +function describeModel(model: Model): string { + const parts = [`Context: ${model.maxTokens.toLocaleString()} tokens`]; + if (model.maxOutputTokens) { + parts.push(`Output: ${model.maxOutputTokens.toLocaleString()} tokens`); + } + if (model.supportsTemperature === false) { + parts.push("Fixed sampling (no temperature)"); + } + if (model.releaseDate) parts.push(`Released ${model.releaseDate}`); + return parts.join(" · "); +} + +/** + * One provider's settings, as a sub-page of Settings → QuickAdd → AI + * Assistant. Like every settings page it saves as you edit: each change is + * written to the store under the provider's id, and the page listens to the + * store so background syncs show up in the model list and the status line. + */ +export class AIProviderSettingPage extends SettingPage { + private unsubscribe: (() => void) | null = null; + private modelFilter = ""; + private modelListEl: HTMLElement | null = null; + private syncStatusEl: HTMLElement | null = null; + private connectionResultEl: HTMLElement | null = null; + + constructor( + private readonly app: App, + private readonly providerId: string, + ) { + super(); + } + + private get provider(): AIProvider | undefined { + return findProvider(this.providerId); + } + + display(): void { + this.teardown(); + this.containerEl.empty(); + + const provider = this.provider; + if (!provider) { + this.containerEl.createDiv({ + cls: "setting-item-description", + text: "This provider no longer exists.", + }); + return; + } + + this.renderConnectionGroup(provider); + this.renderModelSyncGroup(provider); + this.renderModelsGroup(); + this.renderDeleteGroup(); + + let shown = provider; + this.unsubscribe = settingsStore.subscribe(() => { + const current = this.provider; + if (!current || current === shown) return; + const previous = shown; + shown = current; + if (current.models !== previous.models) this.renderModelList(); + if ( + current.lastModelSync !== previous.lastModelSync || + current.models.length !== previous.models.length + ) { + this.renderSyncStatus(); + } + }); + + void this.syncOnOpen(provider); + } + + hide(): void { + this.teardown(); + super.hide(); + } + + private teardown(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.modelListEl = null; + this.syncStatusEl = null; + this.connectionResultEl = null; + } + + /** + * Quiet refresh when the page opens, so the list the user is about to + * browse is current. A failure shows only in the status line; "Sync now" + * is the loud path. + */ + private async syncOnOpen(provider: AIProvider): Promise { + if (!provider.autoSyncModels) return; + if (settingsStore.getState().disableOnlineFeatures) return; + try { + await syncStoredProvider(this.app, this.providerId); + } catch { + // Recorded in lastModelSync; the status line shows it. + } + } + + private edit(update: (provider: AIProvider) => AIProvider): void { + updateProvider(this.providerId, update); + } + + /** An edit that changes what a connection test or sync would talk to. */ + private editConnection(update: (provider: AIProvider) => AIProvider): void { + this.edit((provider) => withoutSyncStatus(update(provider))); + if (this.connectionResultEl) setStatusLine(this.connectionResultEl, ""); + } + + /** + * Keep the page's titles in step with a rename: the inline title here, and + * the settings window's own title, which Obsidian shows as the header on + * phones and only sets when a page opens. + */ + private retitle(title: string): void { + this.title = title; + this.titlebarEl.querySelector(".setting-page-title")?.setText(title); + ( + this.app as unknown as { setting?: { updatePageTitle?: () => void } } + ).setting?.updatePageTitle?.(); + } + + /** Leave this page for the one that opened it (the AI Assistant page). */ + private close(): void { + const setting = ( + this.app as unknown as { setting?: { closePage?: () => void } } + ).setting; + if (typeof setting?.closePage === "function") setting.closePage(); + else this.display(); + } + + private renderConnectionGroup(provider: AIProvider): void { + const group = new SettingGroup(this.containerEl); + + group.addSetting((setting) => { + setting + .setName("Name") + .setDesc( + provider.id + ? `ID: ${provider.id}. Scripts can address this provider's models as "${provider.id}/model-name".` + : "The display name of the provider.", + ) + .addText((text) => { + text.setValue(provider.name).onChange((value) => { + this.edit((p) => ({ ...p, name: value })); + this.retitle(value.trim() || "Untitled provider"); + }); + }); + }); + + group.addSetting((setting) => { + setting + .setName("Endpoint") + .setDesc("The base URL of the provider's API.") + .addText((text) => { + text.setPlaceholder("https://api.example.com/v1"); + text.setValue(provider.endpoint).onChange((value) => { + this.editConnection((p) => ({ ...p, endpoint: value })); + }); + }); + }); + + group.addSetting((setting) => { + setting + .setName("Provider type") + .setDesc( + "The request format this provider expects. Auto-detect recognizes the official Anthropic and Gemini endpoints and treats everything else as OpenAI-compatible; pick a type explicitly for a proxy or custom endpoint.", + ) + .addDropdown((dropdown) => { + dropdown.addOption("", "Auto-detect"); + dropdown.addOption("openai", "OpenAI-compatible"); + dropdown.addOption("anthropic", "Anthropic"); + dropdown.addOption("gemini", "Gemini"); + dropdown.setValue(provider.kind ?? ""); + dropdown.onChange((value) => { + this.editConnection((p) => ({ + ...p, + kind: value ? (value as AIProvider["kind"]) : undefined, + })); + }); + }); + }); + + group.addSetting((setting) => { + configureProviderSecret(setting, this.app, { + value: provider.apiKeyRef ?? "", + hasLegacyKey: !!provider.apiKey && !provider.apiKeyRef, + onChange: (value) => { + this.editConnection((p) => ({ ...p, apiKeyRef: value, apiKey: "" })); + }, + }); + }); + + group.addSetting((setting) => { + setting + .setName("Connection") + .setDesc( + "Check that QuickAdd can reach this provider's models endpoint with the linked key.", + ); + const resultEl = setting.descEl.createDiv({ cls: "qa-ai-status-line" }); + this.connectionResultEl = resultEl; + setting.addButton((button) => { + button.setButtonText("Test connection").onClick(async () => { + const current = this.provider; + if (!current) return; + button.setDisabled(true); + setStatusLine(resultEl, "Testing…"); + try { + const result = await testProviderConnection(this.app, current); + setStatusLine( + resultEl, + describeConnectionResult(result), + result.ok ? "success" : "error", + ); + } finally { + button.setDisabled(false); + } + }); + }); + }); + } + + private renderModelSyncGroup(provider: AIProvider): void { + const source = describeModelSource(provider); + const group = new SettingGroup(this.containerEl).setHeading("Model sync"); + + group.addSetting((setting) => { + setting + .setName("Model source") + .setDesc("Where QuickAdd looks when browsing or syncing models for this provider.") + .addDropdown((dropdown) => { + dropdown.addOption("providerApi", "Provider models endpoint (requires API key)"); + dropdown.addOption("modelsDev", "models.dev directory"); + dropdown.addOption("auto", "Automatic (try provider, fall back to models.dev)"); + dropdown.setValue(provider.modelSource ?? "providerApi"); + dropdown.onChange((value) => { + this.editConnection((p) => ({ + ...p, + modelSource: value as AIProvider["modelSource"], + })); + // The descriptions below name the source. + this.display(); + }); + }); + }); + + group.addSetting((setting) => { + setting + .setName("Auto-sync models") + .setDesc( + `Import new models and refreshed context limits from ${source} once a day and when this page opens.`, + ); + this.syncStatusEl = setting.descEl.createDiv({ cls: "qa-ai-status-line" }); + this.renderSyncStatus(); + setting + .addToggle((toggle) => { + toggle.setValue(!!provider.autoSyncModels).onChange((value) => { + this.edit((p) => ({ ...p, autoSyncModels: value })); + }); + }) + .addButton((button) => { + button.setButtonText("Sync now").onClick(() => this.syncNow(button)); + }); + }); + + group.addSetting((setting) => { + setting + .setName("Browse models") + .setDesc(`Pick models to import from ${source}.`) + .addButton((button) => { + button.setButtonText("Browse…").onClick(() => this.browseModels()); + }); + }); + } + + private async syncNow(button: ButtonComponent): Promise { + const provider = this.provider; + if (!provider) return; + const source = describeModelSource(provider); + // Report against the list on screen now. A background sync may land + // on this provider while the request runs; it is counted too, so the + // notice never says "up to date" while the list visibly changes. + const shown = provider.models.map((model) => ({ ...model })); + button.setDisabled(true); + try { + const discovered = await syncStoredProvider(this.app, this.providerId); + const current = this.provider; + if (!discovered || !current) return; + // Count only models the source reports, so a model the user added + // by hand meanwhile is not announced as synced. + const sourceNames = new Set(discovered.map((m) => m.name)); + const counts = diffModelLists( + shown, + current.models.filter((m) => sourceNames.has(m.name)), + ); + new Notice( + counts.added > 0 || counts.updated > 0 + ? `Synced from ${source}: ${counts.added} new, ${counts.updated} updated.` + : `Synced from ${source}: already up to date.`, + ); + } catch (err) { + new Notice(`Sync failed: ${(err as { message?: string }).message ?? String(err)}`); + } finally { + button.setDisabled(false); + } + } + + private async browseModels(): Promise { + const provider = this.provider; + if (!provider) return; + const result = await new ModelDirectoryModal(this.app, provider).waitForClose; + if (!result) return; + const { imported, mode } = result; + this.edit((p) => ({ + ...p, + // Merge (not append-only dedupe): re-importing a model the provider + // already has refreshes its context/output metadata. + models: mode === "replace" ? imported : mergeModels(p.models, imported), + })); + new Notice( + `Imported ${countModels(imported.length)}${mode === "replace" ? " (replaced the list)" : ""}.`, + ); + } + + private renderModelsGroup(): void { + // mod-list: Obsidian's compact style for collections (as in Keychain), + // which also keeps each row's delete button inline on phones. + const group = new SettingGroup(this.containerEl) + .setHeading("Models") + .addClass("mod-list", "qa-ai-models-group"); + group.addSearch((search) => { + search + .setPlaceholder("Filter models") + .setValue(this.modelFilter) + .onChange((value) => { + this.modelFilter = value; + this.renderModelList(); + }); + }); + group.addExtraButton((button) => { + button + .setIcon("plus") + .setTooltip("Add model") + .onClick(() => void this.addModel()); + }); + this.modelListEl = group.listEl; + this.renderModelList(); + } + + /** (Re)render the model rows. Newest first, retired last. */ + private renderModelList(): void { + const listEl = this.modelListEl; + const provider = this.provider; + if (!listEl || !provider) return; + listEl.empty(); + + const retired = provider.models.filter((model) => model.deprecated); + if (retired.length > 0) { + // Capture the names now: a sync that retires more models while the + // confirmation is open must not widen what gets removed. + const retiredNames = new Set(retired.map((model) => model.name)); + const setting = new Setting(listEl) + .setName(`${countModels(retired.length)} retired by the provider`) + .setDesc( + "Requests to retired models may start failing. Commands that use them will need another model.", + ) + .addButton((button) => { + button.setButtonText("Remove retired").onClick(async () => { + const confirmed = await GenericYesNoPrompt.Prompt( + this.app, + `Remove ${countModels(retiredNames.size)} retired by the provider from ${provider.name}?`, + ); + if (!confirmed) return; + this.edit((p) => ({ + ...p, + models: p.models.filter((model) => !retiredNames.has(model.name)), + })); + }); + }); + setting.settingEl.addClass("qa-ai-retired-notice"); + } + + const query = this.modelFilter.trim().toLowerCase(); + const shown = sortModelsForDisplay(provider.models).filter( + (model) => !query || model.name.toLowerCase().includes(query), + ); + + if (shown.length === 0) { + new Setting(listEl) + .setName( + query + ? `No models match "${this.modelFilter.trim()}".` + : "No models yet. Sync, browse, or add one.", + ) + .settingEl.addClass("mod-empty-state"); + return; + } + + for (const model of shown) { + const setting = new Setting(listEl).setName(model.name).setDesc(describeModel(model)); + if (model.deprecated) { + setting.nameEl.createSpan({ cls: "qa-ai-model-badge", text: "Retired" }); + } + setting.addExtraButton((button) => { + button + .setIcon("trash-2") + .setTooltip("Delete model") + .onClick(() => void this.deleteModel(model.name)); + }); + } + } + + private async deleteModel(name: string): Promise { + const confirmed = await GenericYesNoPrompt.Prompt( + this.app, + `Delete ${name}?`, + ); + if (!confirmed) return; + // By name: a sync may have replaced the model objects meanwhile. + this.edit((p) => ({ + ...p, + models: p.models.filter((model) => model.name !== name), + })); + } + + private async addModel(): Promise { + let modelName: string; + let maxTokens: string; + try { + modelName = await GenericInputPrompt.Prompt(this.app, "Model name"); + maxTokens = await GenericInputPrompt.Prompt(this.app, "Context window (tokens)"); + } catch { + // Cancelling either prompt is a clean no-op. + return; + } + + const name = modelName.trim(); + if (!name) { + new Notice("Model name cannot be empty."); + return; + } + // Reject non-numeric input outright: parseInt would silently accept + // "10abc" as 10. Require a plain positive integer. + const tokens = maxTokens.trim(); + if (!/^[1-9]\d*$/.test(tokens)) { + new Notice("The context window must be a positive whole number."); + return; + } + if (this.provider?.models.some((model) => model.name === name)) { + new Notice(`${name} is already in the list.`); + return; + } + + this.edit((p) => ({ + ...p, + models: [...p.models, { name, maxTokens: Number(tokens) }], + })); + } + + private renderSyncStatus(): void { + const provider = this.provider; + if (!this.syncStatusEl || !provider) return; + setStatusLine( + this.syncStatusEl, + describeSyncStatus(provider, Date.now()), + provider.lastModelSync?.error ? "error" : undefined, + ); + } + + private renderDeleteGroup(): void { + new SettingGroup(this.containerEl).addSetting((setting) => { + setting + .setName("Delete provider") + .setDesc("Commands that use its models will need another model.") + .addButton((button) => { + button + .setButtonText("Delete") + .setWarning() + .onClick(async () => { + const provider = this.provider; + if (!provider) return; + const confirmed = await GenericYesNoPrompt.Prompt( + this.app, + `Delete ${provider.name.trim() || "this provider"}?`, + "Commands that use its models will need another model.", + ); + if (!confirmed) return; + removeProvider(this.providerId); + this.close(); + }); + }); + }); + } +} diff --git a/src/gui/ai/aiAssistantSettingsPage.test.ts b/src/gui/ai/aiAssistantSettingsPage.test.ts new file mode 100644 index 000000000..3b11eda06 --- /dev/null +++ b/src/gui/ai/aiAssistantSettingsPage.test.ts @@ -0,0 +1,153 @@ +import { App, Setting } from "obsidian"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AIProvider } from "src/ai/Provider"; +import { DEFAULT_SETTINGS } from "src/settings"; +import { settingsStore } from "src/settingsStore"; +import { deepClone } from "src/utils/deepClone"; + +const mocks = vi.hoisted(() => ({ + confirm: vi.fn(), + picked: null as AIProvider | null, +})); +vi.mock("../GenericYesNoPrompt/GenericYesNoPrompt", () => ({ + default: { Prompt: mocks.confirm }, +})); +vi.mock("../ProviderPickerModal", () => ({ + ProviderPickerModal: class { + waitForClose: Promise; + constructor(_app: unknown, providers: AIProvider[]) { + if (mocks.picked) providers.push(mocks.picked); + this.waitForClose = Promise.resolve(); + } + }, +})); + +import { aiPageSignature, createAIAssistantPage } from "./aiAssistantSettingsPage"; + +function provider(id: string, name: string, models: AIProvider["models"] = []): AIProvider { + return { id, name, endpoint: `https://${id}.example/v1`, apiKey: "", modelSource: "providerApi", models }; +} + +function providerList() { + const page = createAIAssistantPage(new App()); + return page.items![0] as unknown as { + items: Array<{ type: string; name: string; desc: string; status: unknown; displayValue: unknown }>; + onDelete: (index: number) => void; + addItem: { action: () => void }; + }; +} + +describe("AI Assistant settings page", () => { + beforeEach(() => { + settingsStore.replaceState(deepClone(DEFAULT_SETTINGS)); + mocks.confirm.mockReset(); + mocks.picked = null; + }); + + // Only user edits change the signature; a background sync (new models, + // newly retired ones, sync status) must not rebuild and re-render the tab. + it("signs entry-affecting changes but ignores model and sync metadata", () => { + const base = provider("a", "Alpha", [{ name: "old", maxTokens: 1 }]); + const signature = aiPageSignature([base]); + for (const changed of [ + [base, provider("b", "Beta")], + [], + [{ ...base, name: "Renamed" }], + [{ ...base, endpoint: "https://other.example" }], + ]) expect(aiPageSignature(changed as AIProvider[])).not.toBe(signature); + + expect(aiPageSignature([{ ...base, models: [{ ...base.models[0], deprecated: true }] }])).toBe(signature); + expect(aiPageSignature([{ ...base, models: [{ name: "new", maxTokens: 999 }] }])).toBe(signature); + expect(aiPageSignature([{ ...base, lastModelSync: { at: 123, error: "x" } }])).toBe(signature); + }); + + it("builds uniquely named provider pages whose retired count and warning read the store live", () => { + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [ + provider("a", "Same", [{ name: "retired", maxTokens: 1, deprecated: true }]), + provider("b", "Same"), + ] } })); + + const items = providerList().items; + expect(items.map((item) => item.type)).toEqual(["page", "page"]); + expect(items.map((item) => item.name)).toEqual(["Same (a)", "Same (b)"]); + const live = (value: unknown) => (typeof value === "function" ? value() : value); + expect(items.map((item) => item.desc)).toEqual(["https://a.example/v1", "https://b.example/v1"]); + expect(items.map((item) => live(item.displayValue))).toEqual(["1 retired", ""]); + expect(items.map((item) => live(item.status))).toEqual(["warning", null]); + + // A sync retires a model on "b" after the definitions were built: the + // same definitions show it on their next render, without a rebuild. + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [ + state.ai.providers[0], + { ...state.ai.providers[1], models: [ + { name: "x", maxTokens: 1, deprecated: true }, + { name: "y", maxTokens: 1, deprecated: true }, + ] }, + ] } })); + expect(items.map((item) => live(item.displayValue))).toEqual(["1 retired", "2 retired"]); + expect(live(items[1].status)).toBe("warning"); + }); + + it("deletes the captured index by id only after confirmation", async () => { + const alpha = provider("a", "Alpha"); + const beta = provider("b", "Beta"); + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [alpha, beta] } })); + const list = providerList(); + mocks.confirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + list.onDelete(1); + await vi.waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); + expect(settingsStore.getState().ai.providers.map((p) => p.id)).toEqual(["a", "b"]); + list.onDelete(0); + await vi.waitFor(() => expect(settingsStore.getState().ai.providers.map((p) => p.id)).toEqual(["b"])); + }); + + it("appends exactly the provider returned by the picker", async () => { + const original = provider("a", "Alpha"); + const added = provider("b", "Beta"); + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [original] } })); + mocks.picked = added; + + providerList().addItem.action(); + await vi.waitFor(() => expect(settingsStore.getState().ai.providers).toHaveLength(2)); + expect(settingsStore.getState().ai.providers).toEqual([original, added]); + }); + + // A provider page's auto-sync often lands after the user returned here. + it("refreshes the default model options when providers change, until cleaned up", () => { + settingsStore.setState((state) => ({ ai: { ...state.ai, defaultModel: "Ask me", providers: [ + provider("a", "Alpha", [{ name: "one", maxTokens: 1 }]), + ] } })); + const defaults = createAIAssistantPage(new App()).items![1] as unknown as { + items: Array<{ name: string; render?: (setting: Setting) => (() => void) | void }>; + }; + const row = defaults.items.find((item) => item.name === "Default model")!; + const setting = new Setting(document.createElement("div")); + const cleanup = row.render!(setting) as () => void; + const options = () => Array.from(setting.controlEl.querySelectorAll("option")).map((o) => o.textContent); + expect(options()).toEqual(["Ask me", "one"]); + + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [ + { ...state.ai.providers[0], models: [{ name: "one", maxTokens: 1 }, { name: "two", maxTokens: 1 }] }, + ] } })); + expect(options()).toEqual(["Ask me", "one", "two"]); + + cleanup(); + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [] } })); + expect(options()).toEqual(["Ask me", "one", "two"]); + }); + + it("follows the online gate and uses the nested AI control keys", () => { + settingsStore.setState({ disableOnlineFeatures: false }); + const page = createAIAssistantPage(new App()); + const visible = page.visible as () => boolean; + expect(visible()).toBe(true); + settingsStore.setState({ disableOnlineFeatures: true }); + expect(visible()).toBe(false); + const defaults = page.items![1] as unknown as { items: Array<{ name: string; control?: { key: string } }> }; + expect(defaults.items.filter((item) => item.control).map((item) => [item.name, item.control?.key])).toEqual([ + ["Show assistant", "ai.showAssistant"], + ["Confirm AI tool calls", "ai.confirmToolCalls"], + ]); + }); +}); diff --git a/src/gui/ai/aiAssistantSettingsPage.ts b/src/gui/ai/aiAssistantSettingsPage.ts new file mode 100644 index 000000000..dd59b4930 --- /dev/null +++ b/src/gui/ai/aiAssistantSettingsPage.ts @@ -0,0 +1,229 @@ +import type { + App, + Setting, + SettingDefinitionList, + SettingDefinitionPage, + SettingDefinitionGroup, +} from "obsidian"; +import type { AIProvider } from "src/ai/Provider"; +import { settingsStore } from "src/settingsStore"; +import { getAllFolderPathsInVault } from "src/utilityObsidian"; +import type { SettingsKey } from "../components/settingsDefinitions"; +import GenericYesNoPrompt from "../GenericYesNoPrompt/GenericYesNoPrompt"; +import { populateModelDropdown } from "../modelSelect"; +import { ProviderPickerModal } from "../ProviderPickerModal"; +import { GenericTextSuggester } from "../suggesters/genericTextSuggester"; +import { AIProviderSettingPage } from "./AIProviderSettingPage"; +import { + providerEntryNames, + removeProvider, + storedProviders, + updateAISettings, +} from "./aiSettingsState"; +import { mountSystemPromptLiteralNote } from "./systemPromptLiteralNote"; + +/** The page's name: its entry label, title, and navigation path segment. */ +export const AI_ASSISTANT_PAGE_NAME = "AI Assistant"; + +/** "No providers", "1 provider", "2 providers". */ +export function describeProviderCount(count: number): string { + if (count === 0) return "No providers"; + return `${count} provider${count === 1 ? "" : "s"}`; +} + +function retiredCount(providerId: string | undefined): number { + const provider = storedProviders().find((p) => p.id === providerId); + return provider?.models.filter((model) => model.deprecated).length ?? 0; +} + +/** + * The parts of the AI settings that the definitions are built from, as a + * string that changes exactly when they need rebuilding: a provider added, + * removed, renamed, or moved to another endpoint. Only the user does that. + * Background syncs change models, which the entries read live when they + * render, so a sync never rebuilds (and re-renders) the page under the user. + */ +export function aiPageSignature(providers: readonly AIProvider[]): string { + return JSON.stringify(providers.map((p) => [p.id, p.name, p.endpoint])); +} + +/** + * Settings → QuickAdd → AI Assistant. Replaces the old modal stack (AI + * Assistant settings → Edit providers → provider editor). Built from the + * store's current state; the settings tab calls `update()` when + * `aiPageSignature` changes, which rebuilds it. + */ +export function createAIAssistantPage(app: App): SettingDefinitionPage { + return { + type: "page", + name: AI_ASSISTANT_PAGE_NAME, + desc: "Providers, models, and defaults for AI commands.", + displayValue: () => describeProviderCount(storedProviders().length), + visible: () => !settingsStore.getState().disableOnlineFeatures, + items: [createProvidersList(app), createDefaultsGroup(app)], + }; +} + +function createProvidersList(app: App): SettingDefinitionList { + const providers = storedProviders(); + const names = providerEntryNames(providers); + + return { + type: "list", + heading: "Providers", + emptyState: "No providers yet. Add one to use AI commands.", + addItem: { name: "Add provider", action: () => void addProvider(app) }, + onDelete: (index) => void confirmRemoveProvider(app, providers[index]), + items: providers.map((provider, index) => ({ + type: "page", + name: names[index], + desc: provider.endpoint, + // Evaluated on every render, so returning from the provider's page + // shows what a cleanup or sync did there. + displayValue: () => { + const retired = retiredCount(provider.id); + return retired ? `${retired} retired` : ""; + }, + status: () => (retiredCount(provider.id) ? "warning" : null), + page: () => new AIProviderSettingPage(app, provider.id ?? ""), + })), + }; +} + +async function addProvider(app: App): Promise { + // The picker appends to the array it is given; hand it a copy and add + // whatever it appended, so nothing else in the store is touched. + const draft = [...storedProviders()]; + const before = draft.length; + await new ProviderPickerModal(app, draft).waitForClose; + const added = draft.slice(before); + if (added.length === 0) return; + settingsStore.setState((state) => ({ + ai: { ...state.ai, providers: [...storedProviders(state), ...added] }, + })); +} + +async function confirmRemoveProvider( + app: App, + provider: AIProvider | undefined, +): Promise { + if (!provider?.id) return; + const confirmed = await GenericYesNoPrompt.Prompt( + app, + `Delete ${provider.name.trim() || "this provider"}?`, + "Commands that use its models will need another model.", + ); + if (confirmed) removeProvider(provider.id); +} + +function createDefaultsGroup(app: App): SettingDefinitionGroup { + return { + type: "group", + heading: "Defaults", + items: [ + { + name: "Default model", + desc: "The model new AI commands start with. Ask me picks one each run.", + // Settings search matches names and aliases, not the Providers + // heading; this lands searches for providers on this page. + aliases: ["AI provider", "provider", "API key", "LLM"], + render: (setting) => renderDefaultModel(setting), + }, + { + name: "Prompt template folder", + desc: "The folder QuickAdd reads prompt templates from.", + render: (setting) => renderPromptTemplateFolder(app, setting), + }, + { + name: "Show assistant", + desc: "Show progress notices while the AI Assistant works.", + control: { type: "toggle", key: "ai.showAssistant" }, + }, + { + name: "Confirm AI tool calls", + desc: "When an AI agent runs script-defined or built-in tools, ask before executing. A tool that requires approval is always confirmed.", + control: { + type: "dropdown", + key: "ai.confirmToolCalls", + defaultValue: "destructive", + options: { + destructive: "Destructive tools only (recommended)", + always: "Always confirm every tool", + never: "Never (use each tool's own setting)", + }, + }, + }, + { + name: "Default system prompt", + desc: "The system prompt new AI commands start with.", + render: (setting) => renderDefaultSystemPrompt(setting), + }, + ], + }; +} + +function renderDefaultModel(setting: Setting): () => void { + let unsubscribe = (): void => {}; + setting.addDropdown((dropdown) => { + const populate = (): void => { + const ai = settingsStore.getState().ai; + dropdown.selectEl.empty(); + populateModelDropdown( + dropdown, + { model: ai.defaultModel, modelRef: ai.defaultModelRef }, + (selection) => + updateAISettings({ + defaultModel: selection.model, + defaultModelRef: selection.modelRef, + }), + ); + }; + populate(); + // A provider page's auto-sync often lands after the user came back + // here; list the models it found without rebuilding the page. + let providers = storedProviders(); + unsubscribe = settingsStore.subscribe((state) => { + const next = storedProviders(state); + if (next === providers) return; + providers = next; + populate(); + }); + }); + return () => unsubscribe(); +} + +function renderPromptTemplateFolder(app: App, setting: Setting): () => void { + let suggester: GenericTextSuggester | undefined; + setting.addText((text) => { + text + .setPlaceholder("prompts/") + .setValue(settingsStore.getState().ai.promptTemplatesFolderPath) + .onChange((value) => updateAISettings({ promptTemplatesFolderPath: value })); + suggester = new GenericTextSuggester( + app, + text.inputEl, + getAllFolderPathsInVault(app), + ); + }); + return () => suggester?.destroy(); +} + +function renderDefaultSystemPrompt(setting: Setting): void { + setting.settingEl.addClass("qa-ai-system-prompt-setting"); + const value = settingsStore.getState().ai.defaultSystemPrompt ?? ""; + setting.addTextArea((textArea) => { + textArea.inputEl.addClass("qa-ai-system-prompt-input"); + textArea.inputEl.setAttribute("aria-label", "Default system prompt"); + // No format preview or `{{` autocomplete: the system prompt is sent + // verbatim (see mountSystemPromptLiteralNote). + const updateLiteralNote = mountSystemPromptLiteralNote( + setting.controlEl, + textArea.inputEl, + value, + ); + textArea.setValue(value).onChange((next) => { + updateAISettings({ defaultSystemPrompt: next }); + updateLiteralNote(next); + }); + }); +} diff --git a/src/gui/ai/aiSettingsState.test.ts b/src/gui/ai/aiSettingsState.test.ts new file mode 100644 index 000000000..544b21046 --- /dev/null +++ b/src/gui/ai/aiSettingsState.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_SETTINGS } from "src/settings"; +import { settingsStore } from "src/settingsStore"; +import { deepClone } from "src/utils/deepClone"; +import { providerEntryNames } from "./aiSettingsState"; + +describe("providerEntryNames", () => { + beforeEach(() => settingsStore.replaceState(deepClone(DEFAULT_SETTINGS))); + + it("passes unique names through and labels blank providers", () => { + expect(providerEntryNames([ + { id: "a", name: "Alpha" }, + { id: "blank", name: " " }, + ])).toEqual(["Alpha", "Untitled provider"]); + }); + + it("qualifies duplicate names with stable ids", () => { + expect(providerEntryNames([ + { id: "work", name: "OpenAI" }, + { id: "home", name: "OpenAI" }, + ])).toEqual(["OpenAI (work)", "OpenAI (home)"]); + }); + + it("always produces unique entries for adversarial names", () => { + const result = providerEntryNames([ + { id: "a", name: "A" }, + { id: "b", name: "A" }, + { id: "c", name: "A (a)" }, + { id: "d", name: "" }, + { id: "e", name: "" }, + ]); + + expect(result).toHaveLength(5); + expect(new Set(result).size).toBe(5); + expect(result.slice(0, 3)).toEqual(["A (a)", "A (b)", "A (a) (2)"]); + }); +}); diff --git a/src/gui/ai/aiSettingsState.ts b/src/gui/ai/aiSettingsState.ts new file mode 100644 index 000000000..8f167e107 --- /dev/null +++ b/src/gui/ai/aiSettingsState.ts @@ -0,0 +1,94 @@ +import type { AIProvider } from "src/ai/Provider"; +import type { QuickAddSettings } from "src/settings"; +import { settingsStore } from "src/settingsStore"; + +type AISettings = QuickAddSettings["ai"]; + +/** + * The stored providers, or none. The settings tab builds its definitions + * before migrations run, and legacy data can lack `ai.providers` until + * `addDefaultAIProviders` creates it; reading must not throw, and must not + * write a replacement either. + */ +export function storedProviders( + state: Pick = settingsStore.getState(), +): AIProvider[] { + const providers = (state.ai as Partial | undefined)?.providers; + return Array.isArray(providers) ? providers : []; +} + +/** + * Write helpers for the AI settings pages. Settings pages save as you edit, + * so every change goes straight through the store (main.ts persists it). + * Providers are addressed by their stable id, never by object identity: a + * background sync or another edit may have replaced the object since a page + * rendered it. + */ +export function updateAISettings(patch: Partial): void { + settingsStore.setState((state) => ({ ai: { ...state.ai, ...patch } })); +} + +export function findProvider(id: string): AIProvider | undefined { + return storedProviders().find((p) => p.id === id); +} + +export function updateProvider( + id: string, + update: (provider: AIProvider) => AIProvider, +): void { + settingsStore.setState((state) => ({ + ai: { + ...state.ai, + providers: state.ai.providers.map((provider) => + provider.id === id ? update(provider) : provider, + ), + }, + })); +} + +export function removeProvider(id: string): void { + settingsStore.setState((state) => ({ + ai: { + ...state.ai, + providers: state.ai.providers.filter((provider) => provider.id !== id), + }, + })); +} + +/** + * The provider with its last sync result dropped. Used when the endpoint, + * key, request format, or model source changes: the stored result describes + * a configuration that no longer exists. + */ +export function withoutSyncStatus(provider: AIProvider): AIProvider { + const rest = { ...provider }; + delete rest.lastModelSync; + return rest; +} + +/** + * Names for the providers' entries on the AI Assistant page. Obsidian finds a + * settings sub-page by its name and requires sibling names to be unique, but + * providers may share a name (two OpenAI accounts, or a rename). A shared or + * blank name gets the provider's unique id appended. + */ +export function providerEntryNames( + providers: ReadonlyArray>, +): string[] { + const base = providers.map((p) => p.name.trim() || "Untitled provider"); + const counts = new Map(); + for (const name of base) counts.set(name, (counts.get(name) ?? 0) + 1); + + const taken = new Set(); + return base.map((name, index) => { + let candidate = + (counts.get(name) ?? 0) > 1 && providers[index].id + ? `${name} (${providers[index].id})` + : name; + // Only reachable with hand-edited ids or a name that already reads + // like "Name (id)"; keep the entry reachable anyway. + for (let n = 2; taken.has(candidate); n++) candidate = `${name} (${n})`; + taken.add(candidate); + return candidate; + }); +} diff --git a/src/gui/ai/providerSettings.ts b/src/gui/ai/providerSettings.ts index ac8859281..9aea3a0f5 100644 --- a/src/gui/ai/providerSettings.ts +++ b/src/gui/ai/providerSettings.ts @@ -1,16 +1,31 @@ import type { App } from "obsidian"; import { SecretComponent, Setting } from "obsidian"; -export function addProviderSecret(container: HTMLElement, app: App, options: { +interface ProviderSecretOptions { value: string; onChange: (value: string) => void; hasLegacyKey?: boolean; -}): Setting { - return new Setting(container) +} + +export function addProviderSecret( + container: HTMLElement, + app: App, + options: ProviderSecretOptions, +): Setting { + return configureProviderSecret(new Setting(container), app, options); +} + +/** Turn `setting` into the provider's API key row. */ +export function configureProviderSecret( + setting: Setting, + app: App, + options: ProviderSecretOptions, +): Setting { + return setting .setName("API key") .setDesc(options.hasLegacyKey - ? "Legacy API key detected. Select a SecretStorage entry to migrate." - : "Select a secret from SecretStorage") + ? "A legacy API key is stored in plain text. Link a keychain secret to replace it." + : "Link a secret from Settings → Keychain.") .addComponent((el) => new SecretComponent(app, el) .setValue(options.value) .onChange(options.onChange)); diff --git a/src/gui/ai/syncStatus.test.ts b/src/gui/ai/syncStatus.test.ts index bb8e479b0..9f44e2cea 100644 --- a/src/gui/ai/syncStatus.test.ts +++ b/src/gui/ai/syncStatus.test.ts @@ -23,7 +23,10 @@ describe("describeSyncStatus", () => { expect(describeSyncStatus({ models }, now)).toBe("Not synced yet."); expect( describeSyncStatus({ models, lastModelSync: { at: now - 5 * MINUTE } }, now), - ).toBe("Last synced 5 minutes ago · 2 model(s)."); + ).toBe("Last synced 5 minutes ago · 2 models."); + expect( + describeSyncStatus({ models: [models[0]], lastModelSync: { at: now } }, now), + ).toBe("Last synced just now · 1 model."); expect( describeSyncStatus( { models, lastModelSync: { at: now - 2 * MINUTE, error: "Request failed, status 503" } }, diff --git a/src/gui/ai/syncStatus.ts b/src/gui/ai/syncStatus.ts index c5deba042..ff4d4c62a 100644 --- a/src/gui/ai/syncStatus.ts +++ b/src/gui/ai/syncStatus.ts @@ -1,5 +1,10 @@ import type { AIProvider } from "src/ai/Provider"; +/** "1 model", "3 models". */ +export function countModels(count: number): string { + return `${count} model${count === 1 ? "" : "s"}`; +} + /** "just now", "5 minutes ago", "3 hours ago", "2 days ago". */ export function formatTimeAgo(at: number, now: number): string { const minutes = Math.floor(Math.max(0, now - at) / 60_000); @@ -20,5 +25,5 @@ export function describeSyncStatus( if (!last) return "Not synced yet."; const when = formatTimeAgo(last.at, now); if (last.error) return `Last sync failed ${when}: ${last.error}`; - return `Last synced ${when} · ${provider.models.length} model(s).`; + return `Last synced ${when} · ${countModels(provider.models.length)}.`; } diff --git a/src/gui/ai/systemPromptFields.test.ts b/src/gui/ai/systemPromptFields.test.ts index e1d0ee958..a46d7c4dd 100644 --- a/src/gui/ai/systemPromptFields.test.ts +++ b/src/gui/ai/systemPromptFields.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; /** - * Both system-prompt modals must not offer a format affordance: the system + * Both system-prompt fields (the AI Assistant settings page's default and the + * command modal's own) must not offer a format affordance: the system * prompt reaches the model verbatim (pinned by * AIAssistant.systemPromptLiteral.test.ts), so a live preview resolving its * tokens asserted a substitution that never happens (#1565), and on the shipped @@ -16,13 +17,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ formatDisplayFormatter: vi.fn(), formatSyntaxSuggester: vi.fn(), + defaultSystemPrompt: "", })); vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); vi.mock("src/settingsStore", () => ({ settingsStore: { + setState: vi.fn(), + subscribe: vi.fn(() => () => {}), getState: () => ({ ai: { + defaultSystemPrompt: mocks.defaultSystemPrompt, promptTemplatesFolderPath: "", showAssistant: false, providers: [ @@ -74,10 +79,10 @@ vi.mock("src/gui/suggesters/formatSyntaxSuggester", () => ({ }, })); -import { App } from "obsidian"; +import { App, Setting } from "obsidian"; +import type { SettingDefinitionGroup, SettingDefinitionRender } from "obsidian"; import type { IAIAssistantCommand } from "src/types/macros/QuickCommands/IAIAssistantCommand"; -import type { QuickAddSettings } from "src/settings"; -import { AIAssistantSettingsModal } from "src/gui/AIAssistantSettingsModal"; +import { createAIAssistantPage } from "src/gui/ai/aiAssistantSettingsPage"; import { AIAssistantCommandSettingsModal } from "src/gui/MacroGUIs/AIAssistantCommandSettingsModal"; const PROSE_PROMPT = "As an AI assistant within Obsidian, help the user."; @@ -93,14 +98,29 @@ function testApp(): App { return app; } -function aiSettings(defaultSystemPrompt: string): QuickAddSettings["ai"] { +/** Render the AI Assistant page's "Default system prompt" row, as Obsidian would. */ +function openDefaultSystemPromptRow(systemPrompt: string): OpenedModal { + mocks.defaultSystemPrompt = systemPrompt; + const page = createAIAssistantPage(testApp()); + const row = (page.items ?? []) + .flatMap((item) => (item as SettingDefinitionGroup).items ?? []) + .find((item) => item.name === "Default system prompt") as SettingDefinitionRender; + const contentEl = document.body.createDiv(); + const render = () => { + const setting = new Setting(contentEl).setName(row.name); + row.render(setting, undefined as never); + }; + render(); return { - defaultModel: "gpt-test", - defaultSystemPrompt, - promptTemplatesFolderPath: "", - showAssistant: false, - providers: [], - } as unknown as QuickAddSettings["ai"]; + contentEl, + // Obsidian re-renders a page's rows from scratch on update(). + reload: () => { + contentEl.empty(); + render(); + }, + close: () => contentEl.remove(), + label: "Default system prompt", + }; } function aiCommand(systemPrompt: string): IAIAssistantCommand { @@ -118,8 +138,8 @@ function aiCommand(systemPrompt: string): IAIAssistantCommand { interface OpenedModal { contentEl: HTMLElement; - /** Every one of these modals re-renders in place; the AI settings modal does - * it on every "Edit providers", the command modals on every model change. */ + /** Every one of these re-renders in place: the settings page on update(), + * the command modal on every model change. */ reload: () => void; close: () => void; label: string; @@ -143,12 +163,8 @@ const MODALS: Array<{ open: (systemPrompt: string) => OpenedModal; }> = [ { - name: "AIAssistantSettingsModal (default system prompt)", - open: (systemPrompt) => - opened( - new AIAssistantSettingsModal(testApp(), aiSettings(systemPrompt)), - "Default system prompt", - ), + name: "AI Assistant settings page (default system prompt)", + open: openDefaultSystemPromptRow, }, { name: "AIAssistantCommandSettingsModal (system prompt)", @@ -162,7 +178,7 @@ const MODALS: Array<{ function promptTextarea(contentEl: HTMLElement): HTMLTextAreaElement { const textarea = contentEl.querySelector( - "textarea.qa-ai-prompt-textarea", + "textarea", ); if (!textarea) throw new Error("System prompt textarea not found"); return textarea; diff --git a/src/gui/choiceList/ChoiceView.aiEntry.test.ts b/src/gui/choiceList/ChoiceView.aiEntry.test.ts index 0da47505e..b1650bb02 100644 --- a/src/gui/choiceList/ChoiceView.aiEntry.test.ts +++ b/src/gui/choiceList/ChoiceView.aiEntry.test.ts @@ -7,6 +7,7 @@ import { render } from "@testing-library/svelte"; import ChoiceView from "./ChoiceView.svelte"; import { settingsStore } from "../../settingsStore"; import type QuickAdd from "../../main"; +import type IChoice from "../../types/choices/IChoice"; // Before this, the AI Assistant button lived only in the bottom bar, which the // empty state does not render: a new vault had no way into AI settings. @@ -17,23 +18,46 @@ describe("ChoiceView AI Assistant entry point", () => { settingsStore.setState(initialState, true); }); - function renderEmpty() { - settingsStore.setState({ choices: [] }); + function renderView(choices: IChoice[], openAISettings = vi.fn()) { + settingsStore.setState({ choices }); return render(ChoiceView, { props: { app: new App() as never, plugin: {} as unknown as QuickAdd, - choices: [], + choices, saveChoices: vi.fn(), + openAISettings, }, }); } + const renderEmpty = () => renderView([]); it("offers AI settings in the empty state when online features are on", () => { settingsStore.setState({ disableOnlineFeatures: false }); - const { getByRole } = renderEmpty(); + const openAISettings = vi.fn(); + const { getByRole } = renderView([], openAISettings); - expect(getByRole("button", { name: "Configure AI Assistant" })).toBeTruthy(); + getByRole("button", { name: "Configure AI Assistant" }).click(); + + // The settings tab opens its AI Assistant page; no modal of its own. + expect(openAISettings).toHaveBeenCalledTimes(1); + expect(document.querySelector(".modal")).toBeNull(); + }); + + it("opens the same page from the sparkles button once choices exist", () => { + settingsStore.setState({ disableOnlineFeatures: false }); + const openAISettings = vi.fn(); + const choice = { + id: "c1", + name: "Inbox", + type: "Capture", + command: false, + } as unknown as IChoice; + const { getByRole } = renderView([choice], openAISettings); + + getByRole("button", { name: "Configure AI Assistant" }).click(); + + expect(openAISettings).toHaveBeenCalledTimes(1); }); it("hides it while AI and online features are disabled", () => { diff --git a/src/gui/choiceList/ChoiceView.svelte b/src/gui/choiceList/ChoiceView.svelte index 524dbce91..e6d7be99c 100644 --- a/src/gui/choiceList/ChoiceView.svelte +++ b/src/gui/choiceList/ChoiceView.svelte @@ -7,13 +7,11 @@ import type QuickAdd from "../../main"; import { CommandRegistry } from "../../services/choiceService"; import type IChoice from "../../types/choices/IChoice"; - import { AIAssistantSettingsModal } from "../AIAssistantSettingsModal"; import ObsidianIcon from "../components/ObsidianIcon.svelte"; import { DOCS_URLS } from "../../docs"; import AddChoiceControls from "./AddChoiceControls.svelte"; import ChoiceList from "./ChoiceList.svelte"; import ChoicesUnavailable from "./ChoicesUnavailable.svelte"; - import { reportingHandler } from "../../utils/errorUtils"; import { type Plain } from "../svelte/persist.svelte"; import { createChoiceViewActions } from "./createChoiceViewActions"; @@ -24,12 +22,16 @@ plugin, choices = $bindable([]), saveChoices, + openAISettings, }: { app: App; plugin: QuickAdd; choices?: IChoice[]; // Accepts only Plain (from snapshot()) — see persist.svelte.ts. saveChoices: (choices: Plain) => void; + // Opens Settings → QuickAdd → AI Assistant; the settings tab owns that + // navigation because this view is mounted inside it. + openAISettings: () => void; } = $props(); let filterQuery = $state(""); // not persisted @@ -86,19 +88,6 @@ saveChoices: (value) => saveChoices(value), }); - const openAISettings = reportingHandler( - "Couldn't open the AI assistant settings", - async () => { - const newSettings = await new AIAssistantSettingsModal( - app, - settingsStore.getState().ai, - ).waitForClose; - - if (newSettings) { - settingsStore.setState((state) => ({ ...state, ai: newSettings })); - } - }, - ); diff --git a/src/gui/components/settingsDefinitions.ts b/src/gui/components/settingsDefinitions.ts index d07ab73bb..6dbec4793 100644 --- a/src/gui/components/settingsDefinitions.ts +++ b/src/gui/components/settingsDefinitions.ts @@ -1,8 +1,17 @@ -import type { Setting, SettingDefinitionGroup } from "obsidian"; +import type { + Setting, + SettingDefinitionGroup, + SettingDefinitionPage, +} from "obsidian"; import type { QuickAddSettings } from "../../settings"; import { createDocsLink, DOCS_URLS, openDocsUrl } from "../../docs"; -export type SettingsKey = Extract; +/** Declarative control keys for fields nested under `ai` (see the tab's store bridge). */ +export type AISettingsKey = `ai.${Extract< + keyof QuickAddSettings["ai"], + "showAssistant" | "confirmToolCalls" +>}`; +export type SettingsKey = Extract | AISettingsKey; export const PACKAGES_DESC = "Bundle or import QuickAdd automations as reusable packages."; @@ -19,6 +28,7 @@ type SettingsRenderers = Record< export function createSettingDefinitions( render: SettingsRenderers, isDevBuild: boolean, + aiAssistantPage?: SettingDefinitionPage, ): SettingDefinitionGroup[] { const groups: SettingDefinitionGroup[] = [ { @@ -193,6 +203,7 @@ export function createSettingDefinitions( desc: "This prevents the plugin from making requests to external providers like OpenAI. You can still use user scripts to execute arbitrary code, including contacting external providers. However, this setting disables plugin features like the AI Assistant from doing so. You need to disable this setting to use the AI Assistant.", control: { type: "toggle", key: "disableOnlineFeatures" }, }, + ...(aiAssistantPage ? [aiAssistantPage] : []), { name: "Allow URI x-callback-url", desc: "When on, an obsidian://quickadd URI may open a callback URL (x-success / x-error / x-cancel) after a Template or Capture choice finishes — sending the outcome and the affected note's vault path and URL to that callback. While on, a URI that carries x-* callback params is restricted to Template and Capture choices (other choice types are warned and skipped). Off by default because the callback URL is set by whoever creates the obsidian:// link. Only shortcuts: and obsidian: callback URLs are permitted.", diff --git a/src/quickAddSettingsTab.aiPage.test.ts b/src/quickAddSettingsTab.aiPage.test.ts new file mode 100644 index 000000000..67a1810d8 --- /dev/null +++ b/src/quickAddSettingsTab.aiPage.test.ts @@ -0,0 +1,123 @@ +import { App, Notice, PluginSettingTab } from "obsidian"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AIProvider } from "./ai/Provider"; +import type QuickAdd from "./main"; +import { DEFAULT_SETTINGS } from "./settings"; +import { settingsStore } from "./settingsStore"; +import { deepClone } from "./utils/deepClone"; + +vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); + +import { QuickAddSettingsTab } from "./quickAddSettingsTab"; + +function provider(id: string | undefined, name: string): AIProvider { + return { id, name, endpoint: `https://${name}.example`, apiKey: "", modelSource: "providerApi", models: [] }; +} + +let cleanups: Array<() => void>; +function makeTab(app = new App()): QuickAddSettingsTab { + const plugin = { + app, + register: vi.fn((cleanup: () => void) => cleanups.push(cleanup)), + } as unknown as QuickAdd; + return new QuickAddSettingsTab(app, plugin); +} + +describe("QuickAddSettingsTab AI page bridge", () => { + beforeEach(() => { + settingsStore.replaceState(deepClone(DEFAULT_SETTINGS)); + cleanups = []; + (Notice as unknown as { instances: unknown[] }).instances.length = 0; + }); + + afterEach(() => cleanups.forEach((cleanup) => cleanup())); + + it("reads and writes ai.* fields without clobbering siblings", () => { + const tab = makeTab(); + const before = settingsStore.getState().ai.confirmToolCalls; + tab.setControlValue("ai.showAssistant", false); + expect(tab.getControlValue("ai.showAssistant")).toBe(false); + expect(settingsStore.getState().ai.confirmToolCalls).toBe(before); + }); + + it("updates once per page signature change, not for unrelated writes", () => { + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [provider("a", "Alpha")] } })); + const update = vi.spyOn(PluginSettingTab.prototype, "update"); + makeTab(); + settingsStore.setState({ choices: [] }); + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [{ + ...state.ai.providers[0], models: [{ name: "m", maxTokens: 123 }], + }] } })); + expect(update).not.toHaveBeenCalled(); + + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [{ ...state.ai.providers[0], name: "Renamed" }] } })); + expect(update).toHaveBeenCalledTimes(1); + update.mockRestore(); + }); + + it("backfills missing provider ids without changing existing ids", () => { + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [ + provider(undefined, "My Provider"), provider("fixed", "My Provider"), + ] } })); + makeTab(); + const ids = settingsStore.getState().ai.providers.map((p) => p.id); + expect(ids[1]).toBe("fixed"); + expect(ids[0]).toBeTruthy(); + expect(new Set(ids).size).toBe(2); + }); + + // The tab is constructed (and its definitions built) before migrations run; + // legacy data can lack ai.providers until addDefaultAIProviders creates it. + it("survives pre-migration settings without ai.providers, and writes nothing", () => { + settingsStore.setState({ ai: { OpenAIApiKey: "legacy" } as never }); + const before = settingsStore.getState(); + let tab!: QuickAddSettingsTab; + expect(() => { + tab = makeTab(); + tab.getSettingDefinitions(); + }).not.toThrow(); + expect(settingsStore.getState()).toBe(before); + expect(settingsStore.getState().ai.providers).toBeUndefined(); + }); + + it("repairs two providers claiming the same id, keeping it on the first", () => { + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [ + provider("dup", "First"), provider("dup", "Second"), + ] } })); + makeTab(); + const ids = settingsStore.getState().ai.providers.map((p) => p.id); + expect(ids[0]).toBe("dup"); + expect(ids[1]).not.toBe("dup"); + expect(ids[1]).toBeTruthy(); + }); + + it("leaves the store alone when every provider already has a unique id", () => { + settingsStore.setState((state) => ({ ai: { ...state.ai, providers: [provider("a", "A")] } })); + const before = settingsStore.getState(); + makeTab(); + expect(settingsStore.getState()).toBe(before); + }); + + it("navigates to the AI Assistant page through Obsidian's internal API", () => { + const app = new App() as App & { setting: { navigateToSearchResult: ReturnType } }; + app.setting = { navigateToSearchResult: vi.fn() }; + const tab = makeTab(app); + tab.openAIAssistantPage(); + expect(app.setting.navigateToSearchResult).toHaveBeenCalledWith({ tab, pagePath: ["AI Assistant"] }, null); + }); + + it("falls back to a Notice when internal navigation is unavailable", () => { + makeTab().openAIAssistantPage(); + expect((Notice as unknown as { instances: Array<{ message: string }> }).instances.at(-1)?.message) + .toContain("Open the AI Assistant page"); + }); + + it("places the AI Assistant page in the AI & online group", () => { + const groups = makeTab().getSettingDefinitions() as unknown as Array<{ + heading: string; + items: Array<{ type?: string; name?: string }>; + }>; + const group = groups.find((candidate) => candidate.heading === "AI & online"); + expect(group?.items.some((item) => item.type === "page" && item.name === "AI Assistant")).toBe(true); + }); +}); diff --git a/src/quickAddSettingsTab.audit-cleanup.test.ts b/src/quickAddSettingsTab.audit-cleanup.test.ts index e00121f21..de0ebacd8 100644 --- a/src/quickAddSettingsTab.audit-cleanup.test.ts +++ b/src/quickAddSettingsTab.audit-cleanup.test.ts @@ -10,7 +10,7 @@ vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); function makeTab(): QuickAddSettingsTab { const app = new App(); - const plugin = { app } as unknown as QuickAdd; + const plugin = { app, register: vi.fn() } as unknown as QuickAdd; return new QuickAddSettingsTab(app, plugin); } diff --git a/src/quickAddSettingsTab.test.ts b/src/quickAddSettingsTab.test.ts index 54a233d2a..dd4aede0a 100644 --- a/src/quickAddSettingsTab.test.ts +++ b/src/quickAddSettingsTab.test.ts @@ -149,7 +149,7 @@ describe("settings user-authored DOM XSS safety", () => { describe("QuickAddSettingsTab declarative bridge", () => { function makeTab(): QuickAddSettingsTab { const app = new App(); - const plugin = { app } as unknown as QuickAdd; + const plugin = { app, register: vi.fn() } as unknown as QuickAdd; return new QuickAddSettingsTab(app, plugin); } @@ -364,7 +364,7 @@ describe("Packages row export availability", () => { cleanup: () => void; } { const app = new App(); - const plugin = { app } as unknown as QuickAdd; + const plugin = { app, register: vi.fn() } as unknown as QuickAdd; const tab = new QuickAddSettingsTab(app, plugin); const setting = new Setting(document.createElement("div")); const cleanup = ( diff --git a/src/quickAddSettingsTab.ts b/src/quickAddSettingsTab.ts index b0f7278e4..582fd65ab 100644 --- a/src/quickAddSettingsTab.ts +++ b/src/quickAddSettingsTab.ts @@ -7,6 +7,7 @@ import type { import { ButtonComponent, ExtraButtonComponent, + Notice, PluginSettingTab, TextComponent, } from "obsidian"; @@ -42,6 +43,16 @@ import { type SettingsKey, } from "./gui/components/settingsDefinitions"; import { rootChoicesOf } from "./utils/choiceUtils"; +import { ensureProviderIds } from "./ai/Provider"; +import { + AI_ASSISTANT_PAGE_NAME, + aiPageSignature, + createAIAssistantPage, +} from "./gui/ai/aiAssistantSettingsPage"; +import { tryOpenSettingsPage } from "./utils/openPluginSettings"; +import { storedProviders } from "./gui/ai/aiSettingsState"; + +const AI_KEY_PREFIX = "ai."; export class QuickAddSettingsTab extends PluginSettingTab { public plugin: QuickAdd; @@ -54,6 +65,43 @@ export class QuickAddSettingsTab extends PluginSettingTab { super(app, plugin); this.plugin = plugin; this.icon = "zap"; + + // The AI pages address providers by id. Migrations assign ids, but a + // hand-edited data.json can still hold a provider without one, or two + // providers claiming the same one. + const withIds = storedProviders().map((provider) => ({ ...provider })); + if (ensureProviderIds(withIds)) { + settingsStore.setState((state) => ({ + ai: { ...state.ai, providers: withIds }, + })); + } + + // Declarative definitions are a snapshot: Obsidian re-renders from them + // until update() rebuilds them. Rebuild when the AI page's provider + // entries change (the way Obsidian's own Keychain tab follows its + // secrets), but not on every store write: update() re-renders the page + // on screen. + let signature = aiPageSignature(storedProviders()); + plugin.register( + settingsStore.subscribe((state) => { + const next = aiPageSignature(storedProviders(state)); + if (next === signature) return; + signature = next; + this.update(); + }), + ); + } + + /** + * Settings → QuickAdd → AI Assistant, for the choice list's "Configure AI + * Assistant" buttons, which live on this tab. + */ + openAIAssistantPage(): void { + if (!tryOpenSettingsPage(this.app, this, [AI_ASSISTANT_PAGE_NAME])) { + new Notice( + `QuickAdd: Open the ${AI_ASSISTANT_PAGE_NAME} page under "AI & online" below.`, + ); + } } // ----------------------------------------------------------------------- @@ -73,6 +121,11 @@ export class QuickAddSettingsTab extends PluginSettingTab { override getControlValue(key: string): unknown { const state = settingsStore.getState(); + if (key.startsWith(AI_KEY_PREFIX)) { + const field = key.slice(AI_KEY_PREFIX.length); + return state.ai[field as keyof QuickAddSettings["ai"]]; + } + // `inputPrompt` is stored as an enum but surfaced as a boolean toggle. if (key === "inputPrompt") { return state.inputPrompt === "multi-line"; @@ -82,6 +135,14 @@ export class QuickAddSettingsTab extends PluginSettingTab { } override setControlValue(key: string, value: unknown): void { + if (key.startsWith(AI_KEY_PREFIX)) { + const field = key.slice(AI_KEY_PREFIX.length); + settingsStore.setState((state) => ({ + ai: { ...state.ai, [field]: value }, + })); + return; + } + if (key === "inputPrompt") { settingsStore.setState({ inputPrompt: value ? "multi-line" : "single-line", @@ -109,7 +170,7 @@ export class QuickAddSettingsTab extends PluginSettingTab { templateFolders: (setting) => this.renderTemplateFolderPaths(setting), globalVariables: (setting) => this.renderGlobalVariablesView(setting), developmentInfo: (setting) => this.renderDevInfo(setting), - }, __IS_DEV_BUILD__); + }, __IS_DEV_BUILD__, createAIAssistantPage(this.app)); } override hide(): void { @@ -188,6 +249,7 @@ export class QuickAddSettingsTab extends PluginSettingTab { saveChoices: (choices: Plain) => { settingsStore.setState({ choices }); }, + openAISettings: () => this.openAIAssistantPage(), }, // The choice list is the one view whose failure has a recovery story worth // spelling out (the data.json advice in ChoicesUnavailable), and the same diff --git a/src/styles.css b/src/styles.css index a1c129b60..c1211c96f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1056,20 +1056,49 @@ white-space: nowrap; } -.qa-ai-list-container { - display: flex; - flex-direction: column; - gap: 10px; - overflow-y: auto; - max-height: 400px; - padding: 10px; +.qa-ai-status-line:not(:empty) { + margin-top: var(--size-4-1); } -.qa-ai-provider-button-row { - display: flex; - justify-content: space-between; - margin-top: 20px; - gap: 0.5rem; +.qa-ai-status-line.mod-success { + color: var(--text-success); +} + +.qa-ai-status-line.mod-error { + color: var(--text-error); +} + +.qa-ai-model-badge { + margin-inline-start: var(--size-4-2); + padding: 0 var(--size-4-1); + border-radius: var(--radius-s); + background-color: rgba(var(--color-orange-rgb), 0.15); + color: var(--text-warning); + font-size: var(--font-ui-smaller); + font-weight: var(--font-normal); + vertical-align: middle; +} + +/* The default system prompt spans the row: label and description above a + full-width field, like the template folder list. */ +.qa-ai-system-prompt-setting { + display: block; +} + +.qa-ai-system-prompt-setting .setting-item-control { + display: block; + margin-top: var(--size-4-3); +} + +.qa-ai-system-prompt-input { + width: 100%; + min-height: 8rem; + resize: vertical; + box-sizing: border-box; +} + +.qa-ai-system-prompt-setting .qa-literal-format-note { + margin: var(--size-4-2) 0 0; } .qa-ai-prompt-textarea, diff --git a/src/utils/openPluginSettings.ts b/src/utils/openPluginSettings.ts index 07869a1e2..6e3a01599 100644 --- a/src/utils/openPluginSettings.ts +++ b/src/utils/openPluginSettings.ts @@ -1,4 +1,4 @@ -import type { App } from "obsidian"; +import type { App, SettingTab } from "obsidian"; import { Notice } from "obsidian"; import { log } from "../logger/logManager"; @@ -30,6 +30,40 @@ export function tryOpenPluginSettings(app: App, pluginId: string): boolean { } } +/** + * Opens a sub-page of a settings tab, e.g. Settings → QuickAdd → AI Assistant. + * Obsidian has no public API for this; `navigateToSearchResult` is the + * internal path its own settings search takes to a result on a sub-page + * (Obsidian 1.13). Returns false when that is unavailable or throws. + */ +export function tryOpenSettingsPage( + app: App, + tab: SettingTab, + pagePath: string[], +): boolean { + try { + const setting = ( + app as unknown as { + setting?: { + navigateToSearchResult?: ( + target: { tab: SettingTab; pagePath: string[] }, + match: null, + ) => void; + }; + } + ).setting; + if (typeof setting?.navigateToSearchResult !== "function") { + log.logMessage("QuickAdd: Obsidian's settings page navigation is unavailable."); + return false; + } + setting.navigateToSearchResult({ tab, pagePath }, null); + return true; + } catch (error) { + log.logMessage(`QuickAdd: Failed to open settings page: ${error}`); + return false; + } +} + /** * Opens QuickAdd's settings tab, telling the user how to get there by hand when * the internal API is unavailable. Lives here rather than in `main.ts` so leaf diff --git a/tests/obsidian-stub.ts b/tests/obsidian-stub.ts index 351268380..4d26bb65a 100644 --- a/tests/obsidian-stub.ts +++ b/tests/obsidian-stub.ts @@ -55,6 +55,16 @@ export class ButtonComponent extends BaseComponent { return this; } + setWarning(): this { + this.buttonEl.classList.add("mod-warning"); + return this; + } + + setDestructive(): this { + this.buttonEl.classList.add("mod-destructive"); + return this; + } + // Mirrors Obsidian: BaseComponent tracks the flag, the native button carries // it, and the tooltip is driven by aria-label (a delegated pointerover handler). setDisabled(disabled: boolean): this { @@ -391,16 +401,39 @@ export class Setting { this.components.push(component); return this; } + + addExtraButton(cb: (component: ExtraButtonComponent) => any): this { + cb(new ExtraButtonComponent(this.controlEl)); + return this; + } } +// Obsidian 1.11+: groupEl > [headerEl, listEl]. Settings added through +// addSetting land in listEl, and header controls (search, extra buttons) in +// headerEl, as in the real app. export class SettingGroup { groupEl: HTMLElement; + headerEl: HTMLElement; + listEl: HTMLElement; constructor(containerEl: HTMLElement) { this.groupEl = document.createElement("div"); + this.headerEl = document.createElement("div"); + this.listEl = document.createElement("div"); + this.groupEl.append(this.headerEl, this.listEl); containerEl.appendChild(this.groupEl); } + addSearch(cb: (component: SearchComponent) => any): this { + cb(new SearchComponent(this.headerEl)); + return this; + } + + addExtraButton(cb: (component: ExtraButtonComponent) => any): this { + cb(new ExtraButtonComponent(this.headerEl)); + return this; + } + setHeading(text: string | DocumentFragment): this { const headingEl = document.createElement("h3"); if (typeof text === "string") { @@ -408,21 +441,39 @@ export class SettingGroup { } else { headingEl.appendChild(text); } - this.groupEl.appendChild(headingEl); + this.headerEl.prepend(headingEl); return this; } - addClass(cls: string): this { - this.groupEl.classList.add(cls); + addClass(...classes: string[]): this { + this.groupEl.classList.add(...classes); return this; } addSetting(cb: (setting: Setting) => void): this { - cb(new Setting(this.groupEl)); + cb(new Setting(this.listEl)); return this; } } +// Obsidian 1.13 sub-page: rootEl > [titlebarEl, containerEl]. +export abstract class SettingPage { + rootEl: HTMLElement; + titlebarEl: HTMLElement; + containerEl: HTMLElement; + title = ""; + + constructor() { + this.rootEl = document.createElement("div"); + this.titlebarEl = document.createElement("div"); + this.containerEl = document.createElement("div"); + this.rootEl.append(this.titlebarEl, this.containerEl); + } + + abstract display(): void; + hide(): void {} +} + // Ensure window and global moment are available (globalThis as any).window ??= globalThis; (globalThis as any).window.moment = moment; @@ -514,6 +565,8 @@ export class PluginSettingTab { display() {} hide() {} + update() {} + refreshDomState() {} } export class TFile { @@ -1035,6 +1088,7 @@ export default { PluginSettingTab, Setting, SettingGroup, + SettingPage, TFile, TFolder, MarkdownView, From f7fc664f5d8b2de22f8b135401e98863e9aafda6 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 27 Sep 2026 00:01:04 +0000 Subject: [PATCH 2/5] fix(ai): clearer Default model and retired-row copy Co-authored-by: Christian Bager Bach Houmann --- src/gui/ai/AIProviderSettingPage.ts | 2 +- src/gui/ai/aiAssistantSettingsPage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/ai/AIProviderSettingPage.ts b/src/gui/ai/AIProviderSettingPage.ts index 79e4dbe05..91dfc5c79 100644 --- a/src/gui/ai/AIProviderSettingPage.ts +++ b/src/gui/ai/AIProviderSettingPage.ts @@ -409,7 +409,7 @@ export class AIProviderSettingPage extends SettingPage { const setting = new Setting(listEl) .setName(`${countModels(retired.length)} retired by the provider`) .setDesc( - "Requests to retired models may start failing. Commands that use them will need another model.", + "Requests to them may fail. Commands that use them need another model.", ) .addButton((button) => { button.setButtonText("Remove retired").onClick(async () => { diff --git a/src/gui/ai/aiAssistantSettingsPage.ts b/src/gui/ai/aiAssistantSettingsPage.ts index dd59b4930..1bb92ad05 100644 --- a/src/gui/ai/aiAssistantSettingsPage.ts +++ b/src/gui/ai/aiAssistantSettingsPage.ts @@ -123,7 +123,7 @@ function createDefaultsGroup(app: App): SettingDefinitionGroup { items: [ { name: "Default model", - desc: "The model new AI commands start with. Ask me picks one each run.", + desc: "The model new AI commands start with. “Ask me” prompts you to choose a model each run.", // Settings search matches names and aliases, not the Providers // heading; this lands searches for providers on this page. aliases: ["AI provider", "provider", "API key", "LLM"], From c9212e31ea304fde7e8e7debaaabbd057ae642fc Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 27 Sep 2026 00:05:17 +0000 Subject: [PATCH 3/5] test: update comment naming the removed AI settings modal Co-authored-by: Christian Bager Bach Houmann --- src/ai/AIAssistant.systemPromptLiteral.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/AIAssistant.systemPromptLiteral.test.ts b/src/ai/AIAssistant.systemPromptLiteral.test.ts index 046ca0331..3e1c700d6 100644 --- a/src/ai/AIAssistant.systemPromptLiteral.test.ts +++ b/src/ai/AIAssistant.systemPromptLiteral.test.ts @@ -18,7 +18,7 @@ import type { CommonResponse } from "./OpenAIRequest"; * * If #1572 ever makes system prompts formattable, this test fails, and whoever * changes it is the person who should also restore the preview and the token - * autocomplete in AIAssistantSettingsModal / AIAssistantCommandSettingsModal. + * autocomplete in the old AI Assistant settings modal and AIAssistantCommandSettingsModal. * (A third modal, AIAssistantInfiniteCommandSettingsModal, carried the same * affordance; it went with the unreachable command type it configured, #1571.) */ From c6161292f15d8bcc9937eea1d4ef3a945bdfdff6 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 27 Sep 2026 00:11:59 +0000 Subject: [PATCH 4/5] test: pass openAISettings to ChoiceView in component tests Co-authored-by: Christian Bager Bach Houmann --- src/gui/choiceList/ChoiceView.addRace.test.ts | 1 + src/gui/choiceList/ChoiceView.malformed.test.ts | 1 + src/gui/choiceList/ChoiceView.test.ts | 2 ++ tests/helpers/settings/choiceView.ts | 2 +- 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/gui/choiceList/ChoiceView.addRace.test.ts b/src/gui/choiceList/ChoiceView.addRace.test.ts index c68a10186..33e6891bd 100644 --- a/src/gui/choiceList/ChoiceView.addRace.test.ts +++ b/src/gui/choiceList/ChoiceView.addRace.test.ts @@ -72,6 +72,7 @@ describe("ChoiceView new-choice race (#1625)", () => { plugin: {} as unknown as QuickAdd, choices: settingsStore.getState().choices, saveChoices, + openAISettings: vi.fn(), }, }); diff --git a/src/gui/choiceList/ChoiceView.malformed.test.ts b/src/gui/choiceList/ChoiceView.malformed.test.ts index 50ef50243..0bb80275b 100644 --- a/src/gui/choiceList/ChoiceView.malformed.test.ts +++ b/src/gui/choiceList/ChoiceView.malformed.test.ts @@ -261,6 +261,7 @@ describe("ChoiceView over a malformed tree (#1566)", () => { plugin: { addCommandForChoice } as unknown as QuickAdd, choices: tree, saveChoices: vi.fn(), + openAISettings: vi.fn(), }, }); const id = container diff --git a/src/gui/choiceList/ChoiceView.test.ts b/src/gui/choiceList/ChoiceView.test.ts index abb2fe0c6..f57dbf8eb 100644 --- a/src/gui/choiceList/ChoiceView.test.ts +++ b/src/gui/choiceList/ChoiceView.test.ts @@ -201,6 +201,7 @@ describe("ChoiceView", () => { } as unknown as QuickAdd, choices: [folderChoice], saveChoices, + openAISettings: vi.fn(), }, }); @@ -245,6 +246,7 @@ describe("ChoiceView", () => { plugin: {} as unknown as QuickAdd, choices: [folderChoice], saveChoices, + openAISettings: vi.fn(), }, }); diff --git a/tests/helpers/settings/choiceView.ts b/tests/helpers/settings/choiceView.ts index cfa2b1360..602014f32 100644 --- a/tests/helpers/settings/choiceView.ts +++ b/tests/helpers/settings/choiceView.ts @@ -11,6 +11,6 @@ export function renderChoiceView( saveChoices: (next: Plain) => void = vi.fn(), ) { return render(ChoiceView, { - props: { app: new App(), plugin: {} as QuickAdd, choices, saveChoices }, + props: { app: new App(), plugin: {} as QuickAdd, choices, saveChoices, openAISettings: () => {} }, }); } From 8df21d7299be2a10728867ae989b0fd356875907 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 27 Sep 2026 00:18:15 +0000 Subject: [PATCH 5/5] feat(ai): Open AI Assistant settings command; reject unsafe context sizes Co-authored-by: Christian Bager Bach Houmann --- src/commandLabels.ts | 1 + src/gui/ai/AIProviderSettingPage.test.ts | 1 + src/gui/ai/AIProviderSettingPage.ts | 4 ++- src/main.commandLabels.test.ts | 1 + src/main.ts | 8 +++++- src/quickAddSettingsTab.aiPage.test.ts | 33 ++++++++++++++++++++++++ src/quickAddSettingsTab.ts | 21 ++++++++++++++- 7 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/commandLabels.ts b/src/commandLabels.ts index 0e9f5b768..e6bd4130f 100644 --- a/src/commandLabels.ts +++ b/src/commandLabels.ts @@ -4,5 +4,6 @@ export const QUICK_ADD_COMMAND_LABELS = { applyTemplate: "Apply template to active note", reloadDev: "Reload (dev)", openSettings: "Open settings", + openAISettings: "Open AI Assistant settings", resumePrompt: "Return to prompt", } as const; diff --git a/src/gui/ai/AIProviderSettingPage.test.ts b/src/gui/ai/AIProviderSettingPage.test.ts index 762cc2f38..5f11618f2 100644 --- a/src/gui/ai/AIProviderSettingPage.test.ts +++ b/src/gui/ai/AIProviderSettingPage.test.ts @@ -179,6 +179,7 @@ describe("AIProviderSettingPage", () => { ["blank name", [" ", "10"]], ["non-numeric tokens", ["model", "wat"]], ["trailing junk", ["model", "10abc"]], + ["beyond safe integers", ["model", "9".repeat(400)]], ["duplicate", ["old", "10"]], ] as const)("rejects invalid Add model input: %s", async (_label, answers) => { install(provider()); diff --git a/src/gui/ai/AIProviderSettingPage.ts b/src/gui/ai/AIProviderSettingPage.ts index 91dfc5c79..bed841099 100644 --- a/src/gui/ai/AIProviderSettingPage.ts +++ b/src/gui/ai/AIProviderSettingPage.ts @@ -489,7 +489,9 @@ export class AIProviderSettingPage extends SettingPage { // Reject non-numeric input outright: parseInt would silently accept // "10abc" as 10. Require a plain positive integer. const tokens = maxTokens.trim(); - if (!/^[1-9]\d*$/.test(tokens)) { + // Past 2^53 a digit string no longer parses to itself (and a long one + // to Infinity, which data.json stores as null). + if (!/^[1-9]\d*$/.test(tokens) || !Number.isSafeInteger(Number(tokens))) { new Notice("The context window must be a positive whole number."); return; } diff --git a/src/main.commandLabels.test.ts b/src/main.commandLabels.test.ts index e44ca5fac..d68ce16f6 100644 --- a/src/main.commandLabels.test.ts +++ b/src/main.commandLabels.test.ts @@ -9,6 +9,7 @@ describe("QuickAdd command labels", () => { applyTemplate: "Apply template to active note", reloadDev: "Reload (dev)", openSettings: "Open settings", + openAISettings: "Open AI Assistant settings", resumePrompt: "Return to prompt", }); diff --git a/src/main.ts b/src/main.ts index 0cfaad203..60adb767f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -162,7 +162,13 @@ export default class QuickAdd extends Plugin { }); } - this.addSettingTab(new QuickAddSettingsTab(this.app, this)); + const settingsTab = new QuickAddSettingsTab(this.app, this); + this.addSettingTab(settingsTab); + this.addCommand({ + id: "openAIAssistantSettings", + name: QUICK_ADD_COMMAND_LABELS.openAISettings, + callback: () => settingsTab.openAIAssistantPageFromCommand(), + }); // Everything from here on reads the choice tree, i.e. untrusted data.json. // Each step is isolated so a defect in that data costs one capability diff --git a/src/quickAddSettingsTab.aiPage.test.ts b/src/quickAddSettingsTab.aiPage.test.ts index 67a1810d8..1f4ef0bdd 100644 --- a/src/quickAddSettingsTab.aiPage.test.ts +++ b/src/quickAddSettingsTab.aiPage.test.ts @@ -106,6 +106,39 @@ describe("QuickAddSettingsTab AI page bridge", () => { expect(app.setting.navigateToSearchResult).toHaveBeenCalledWith({ tab, pagePath: ["AI Assistant"] }, null); }); + it("the command opens the settings window on QuickAdd, then the AI Assistant page", () => { + settingsStore.setState({ disableOnlineFeatures: false }); + const calls: string[] = []; + const app = Object.assign(new App(), { + setting: { + open: vi.fn(() => calls.push("open")), + openTabById: vi.fn((id: string) => calls.push(`tab:${id}`)), + navigateToSearchResult: vi.fn((target: { pagePath: string[] }) => + calls.push(`page:${target.pagePath.join(">")}`), + ), + }, + }); + const plugin = { + app, + manifest: { id: "quickadd" }, + register: vi.fn((cleanup: () => void) => cleanups.push(cleanup)), + } as unknown as QuickAdd; + new QuickAddSettingsTab(app as unknown as App, plugin).openAIAssistantPageFromCommand(); + expect(calls).toEqual(["open", "tab:quickadd", "page:AI Assistant"]); + }); + + it("the command explains itself instead of opening a hidden page while AI is off", () => { + settingsStore.setState({ disableOnlineFeatures: true }); + const navigate = vi.fn(); + const app = Object.assign(new App(), { + setting: { open: vi.fn(), openTabById: vi.fn(), navigateToSearchResult: navigate }, + }); + makeTab(app as unknown as App).openAIAssistantPageFromCommand(); + expect(navigate).not.toHaveBeenCalled(); + expect((Notice as unknown as { instances: Array<{ message: string }> }).instances.at(-1)?.message) + .toContain("Disable AI & online features"); + }); + it("falls back to a Notice when internal navigation is unavailable", () => { makeTab().openAIAssistantPage(); expect((Notice as unknown as { instances: Array<{ message: string }> }).instances.at(-1)?.message) diff --git a/src/quickAddSettingsTab.ts b/src/quickAddSettingsTab.ts index 582fd65ab..dfdc87e9c 100644 --- a/src/quickAddSettingsTab.ts +++ b/src/quickAddSettingsTab.ts @@ -49,7 +49,10 @@ import { aiPageSignature, createAIAssistantPage, } from "./gui/ai/aiAssistantSettingsPage"; -import { tryOpenSettingsPage } from "./utils/openPluginSettings"; +import { + openQuickAddSettings, + tryOpenSettingsPage, +} from "./utils/openPluginSettings"; import { storedProviders } from "./gui/ai/aiSettingsState"; const AI_KEY_PREFIX = "ai."; @@ -92,6 +95,22 @@ export class QuickAddSettingsTab extends PluginSettingTab { ); } + /** + * The "Open AI Assistant settings" command: open the settings window on + * this tab, then the AI Assistant page. The page is hidden while AI and + * online features are off, so say so instead of showing it anyway. + */ + openAIAssistantPageFromCommand(): void { + if (settingsStore.getState().disableOnlineFeatures) { + new Notice( + "QuickAdd: Turn off “Disable AI & online features” in QuickAdd settings to use the AI Assistant.", + ); + return; + } + if (!openQuickAddSettings(this.app, this.plugin.manifest.id)) return; + this.openAIAssistantPage(); + } + /** * Settings → QuickAdd → AI Assistant, for the choice list's "Configure AI * Assistant" buttons, which live on this tab.