Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 36 additions & 25 deletions docs/src/content/docs/docs/AIAssistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -113,18 +120,18 @@ 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
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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/docs/QuickAddAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<object>`
Splits `text` into chunks, runs `promptTemplate` once per chunk, and joins the
Expand Down
2 changes: 1 addition & 1 deletion src/ai/AIAssistant.systemPromptLiteral.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
*/
Expand Down
2 changes: 1 addition & 1 deletion src/ai/aiHelpers.resolveModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand Down
8 changes: 3 additions & 5 deletions src/ai/aiHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
Expand Down
79 changes: 79 additions & 0 deletions src/ai/modelSyncService.syncStoredProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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<Model[]>((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<Model[]>((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();
});
});
52 changes: 52 additions & 0 deletions src/ai/modelSyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Model[] | null> {
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.
Expand Down
3 changes: 2 additions & 1 deletion src/ai/providerConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.)"}`;
}
1 change: 1 addition & 0 deletions src/commandLabels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading