diff --git a/CLAUDE.md b/CLAUDE.md index 92444e5..530e996 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ The `PipelineSource` union has three variants: `audio` (recorded blob, optional API keys are stored per profile on `EnvironmentProfile.transcriptionConfig.apiKey` / `llmConfig.apiKey`. Two slots per profile, one for transcription and one for the LLM. No global by-family map; the desktop and mobile profiles each carry their own keys even when both use the same provider (deliberate: per-profile keys make per-function usage tracking easier). Persistence is in [src/secrets.ts](src/secrets.ts) using the key IDs `profile:desktop:transcription`, `profile:desktop:llm`, `profile:mobile:transcription`, `profile:mobile:llm`. -Providers may optionally implement `listModels(config, signal)` returning a string array of model IDs the configured API key can access. Implemented by: OpenAI / Groq / Mistral (via `openai.ts` shared adapter), Mistral Voxtral (own adapter, filters Mistral's `/v1/models` catalog by ID substring `voxtral`), Anthropic, Gemini, Deepgram. Not implemented for `openai-compatible` (URL-specific, list-shape varies), AssemblyAI, Rev.ai. The settings tab caches results to `GlobalSettings.modelCache` per side and provider ID; the Refresh button in the model field triggers `listModels` and updates the cache. The model field is a single adaptive control (`populateModelField` in [src/settings/tab.ts](src/settings/tab.ts), shared by both sides via a `side: 'transcription' | 'llm'` arg): a **dropdown** when the provider supports `listModels` and the cache is non-empty, otherwise a **plain text field**. There is never both at once. The dropdown carries a trailing "Custom..." option (sentinel `CUSTOM_MODEL_OPTION`, never written to `config.model`) that toggles the same control into the text field (the `forceText` arg) so a model ID not in the catalog can still be typed; a "Back to list" button returns to the dropdown. A custom value not in the cache is shown as a selected ` (custom)` dropdown option. The Refresh button accompanies the dropdown (and the empty-cache text field, so the first fetch can flip it to a dropdown); on success the field re-renders in dropdown mode. `openai-compatible` / AssemblyAI / Rev.ai (no `listModels`) always show a plain text field with no Refresh. Whichever control is active, the canonical source is `profile.config.model`. +Providers may optionally implement `listModels(config, signal)` returning a string array of model IDs the configured API key can access. Implemented by: OpenAI / Groq / Mistral (via `openai.ts` shared adapter), Mistral Voxtral (own adapter, filters Mistral's `/v1/models` catalog by ID substring `voxtral`), Anthropic, Gemini, Deepgram. Not implemented for `openai-compatible` (URL-specific, list-shape varies), AssemblyAI, Rev.ai. The settings tab caches results to `GlobalSettings.modelCache` per side and provider ID; the Refresh button in the model field triggers `listModels` and updates the cache. The model field is a single adaptive control (`populateModelField` in [src/settings/tab.ts](src/settings/tab.ts), shared by both sides via a `side: 'transcription' | 'llm'` arg): a **dropdown** when the provider supports `listModels` and the cache is non-empty, otherwise a **plain text field**. There is never both at once. The dropdown carries a trailing "Custom..." option (sentinel `CUSTOM_MODEL_OPTION`, never written to `config.model`) that toggles the same control into the text field (the `forceText` arg) so a model ID not in the catalog can still be typed; a "Back to list" button returns to the dropdown. A custom value not in the cache is shown as a selected ` (custom)` dropdown option. The Refresh button accompanies the dropdown (and the empty-cache text field, so the first fetch can flip it to a dropdown); on success the field re-renders in dropdown mode. `openai-compatible` / AssemblyAI / Rev.ai (no `listModels`) always show a plain text field with no Refresh. For AssemblyAI and Rev.ai the field description appends a "list of models" external link to the provider's docs (`transcriptionModelDocsUrl` + `applyModelFieldDesc` in [src/settings/tab.ts](src/settings/tab.ts)); `openai-compatible` has none (the model list is the user's own server). Whichever control is active, the canonical source is `profile.config.model`. ## Speaker diarization diff --git a/src/settings/tab.ts b/src/settings/tab.ts index d889d01..8060185 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -555,9 +555,13 @@ export class ReWriteSettingTab extends PluginSettingTab { ? 'empty-cache' : 'dropdown'; + const docsUrl = isTranscription + ? transcriptionModelDocsUrl(profile.transcriptionProvider) + : null; + const setting = new Setting(wrapper) - .setName(isTranscription ? 'Transcription model' : 'LLM model') - .setDesc(modelFieldDesc(hint, mode)); + .setName(isTranscription ? 'Transcription model' : 'LLM model'); + applyModelFieldDesc(setting, hint, mode, docsUrl); const refresh = async (): Promise => { if (isTranscription) { @@ -1108,6 +1112,44 @@ function modelFieldDesc(hint: string, mode: ModelFieldMode): string { } } +/** + * Sets the model-field description, appending a "list of models" external link when + * the provider has no listModels endpoint but does publish a models doc page + * (assemblyai, revai). Providers without a doc link get the plain string desc. + */ +function applyModelFieldDesc( + setting: Setting, + hint: string, + mode: ModelFieldMode, + docsUrl: string | null, +): void { + const text = modelFieldDesc(hint, mode); + if (!docsUrl) { + setting.setDesc(text); + return; + } + const linkLabel = 'list of models'; + const frag = document.createDocumentFragment(); + frag.appendText(text ? `${text} See the ` : 'See the '); + const a = frag.createEl('a', { text: linkLabel, href: docsUrl }); + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + frag.appendText('.'); + setting.setDesc(frag); +} + +/** Models documentation page for providers that have no listModels endpoint. */ +function transcriptionModelDocsUrl(id: TranscriptionProviderID): string | null { + switch (id) { + case 'assemblyai': + return 'https://www.assemblyai.com/docs/getting-started/models'; + case 'revai': + return 'https://docs.rev.ai/api/asynchronous/transcribers/'; + default: + return null; + } +} + function transcriptionModelHint(id: TranscriptionProviderID): string { switch (id) { case 'none':