feat(conversation): pick the LLM endpoint from the configured language models

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-06 00:33:30 +02:00
parent 8ce8e791b2
commit dea6286775
8 changed files with 162 additions and 6 deletions

View File

@ -10,6 +10,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- Conversation Playground: a Thinking on/off switch next to the Language Model (default off, setting `conversation_thinking`). Off sends `chat_template_kwargs.enable_thinking=false` (plus `think:false` for Ollama) with each reply request and never touches the system prompt; the empty-reply error now says to turn Thinking off.
### Changed
- Conversation Playground: the Language Model base URL is now picked from a dropdown of the language models configured under Engines (Docker LLM containers incl. saved URL overrides, and custom LLM cards; running/stopped marked), with a "Custom…" option that reveals the free-text URL. Selecting an entry saves `conv_llm_url` and fetches the models.
- Playwright specs now import `test` from `tests/_guard.js`, whose auto fixture aborts and fails any unmocked non-GET request to `/api/**` so a spec can no longer modify live settings. See `architecture/qa.md`.
---

View File

@ -844,6 +844,22 @@ same library and reading progress. Playback needs the server; there is no
offline cache yet.
## Conversation Playground: Language Model endpoint picker
**Purpose:** choose the LLM endpoint from the language models you configured under Engines → Language Models instead of typing a URL.
**Prerequisites:** at least one language model (Docker container or custom card) under Engines; otherwise only "Custom…" is offered, with a link to Engines.
**Usage:** in Engines & Prompt open the Language Model dropdown (entries show label and URL; ✓ = running, ✗ = stopped). Picking an entry saves it and loads its models into the model list. Choose "Custom…" to type any URL; the field appears right after the dropdown and takes focus. The refresh button re-fetches models.
**Expected result:** the saved URL is preselected on reload; an unknown saved URL shows "Custom…" with the URL in the field.
**Errors:** an unreachable endpoint shows "Fetch failed" in the model list. If the container list cannot be loaded, "Custom…" still works.
**Mobile / accessibility:** controls stack on phone width, 44 px targets, visible label, keyboard operable, German and English.
**Offline / privacy:** only your configured endpoint is contacted; the URL is stored in local app settings.
## Conversation Playground: Thinking switch
**Purpose:** choose whether a reasoning model (e.g. Qwen3) thinks before replying. Off (default) gives faster answers; on gives better reasoning but is slower and some models then return an empty reply.

View File

@ -198,10 +198,62 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
// Restore conv LLM URL from server settings — fall back to the global Active LLM endpoint
if (llmUrlInp && _appSettings) llmUrlInp.value = _appSettings.conv_llm_url || _appSettings.llm_url || '';
if (llmUrlInp) llmUrlInp.addEventListener('input', () => {
const saveLlmUrl = () => {
_patchSettings({ conv_llm_url: llmUrlInp.value });
if (_appSettings) _appSettings.conv_llm_url = llmUrlInp.value;
});
};
if (llmUrlInp) llmUrlInp.addEventListener('input', saveLlmUrl);
// Endpoint picker: language models configured under Engines → Language Models
// (running/stopped Docker LLM containers from /api/local-containers with their saved
// URL override or default port, plus custom LLM cards). "Custom…" shows the free-text URL.
const llmEpSel = $('conv-llm-endpoint');
const llmEpHint = $('conv-llm-empty-hint');
const CUSTOM = '__custom__';
const _tr = s => (window.t ? window.t(s) : s);
const syncLlmPicker = () => {
if (!llmEpSel || !llmUrlInp) return;
const cur = llmUrlInp.value.trim();
const hit = cur && [...llmEpSel.options].find(o => o.value === cur);
llmEpSel.value = hit ? cur : CUSTOM;
llmUrlInp.hidden = !!hit;
};
async function loadLlmEndpoints() {
if (!llmEpSel) return;
const list = [];
const seen = new Set();
const add = (label, url, status) => { if (url && !seen.has(url)) { seen.add(url); list.push({ label, url, status }); } };
try {
const r = await fetch('/api/local-containers', { cache: 'no-store' });
const d = r.ok ? await r.json() : {};
const over = (_appSettings && _appSettings.engine_local_urls) || {};
for (const c of (d.containers || [])) {
if (c.role !== 'llm' || c.status === 'not_found') continue;
add(c.label || c.name, over['dc-' + c.name] || (c.port ? `http://host.docker.internal:${c.port}/v1` : ''), c.status);
}
} catch (_) { /* list stays partial; Custom… still works */ }
try {
const custom = typeof loadCustomEngineCards === 'function' ? loadCustomEngineCards() : [];
for (const c of custom) if (c && c.role === 'llm') add(c.label || c.name, c.url, 'custom');
} catch (_) {}
llmEpSel.innerHTML = list.map(e => {
const mark = e.status === 'running' ? '✓ ' : (e.status === 'custom' ? '' : '✗ ');
return `<option value="${escHtml(e.url)}">${escHtml(mark + e.label + ' — ' + e.url)}</option>`;
}).join('') + `<option value="${CUSTOM}">${escHtml(_tr('Custom…'))}</option>`;
if (llmEpHint) llmEpHint.hidden = list.length > 0;
syncLlmPicker();
}
if (llmEpSel && llmUrlInp) {
llmUrlInp.addEventListener('url-sync', syncLlmPicker);
llmEpSel.addEventListener('change', () => {
if (llmEpSel.value === CUSTOM) { llmUrlInp.hidden = false; llmUrlInp.focus(); return; }
llmUrlInp.value = llmEpSel.value;
llmUrlInp.hidden = true;
saveLlmUrl();
fetchLlmModels();
});
loadLlmEndpoints();
}
// Thinking switch (server setting conversation_thinking; default off = faster answers)
const thinkingSw = $('conv-thinking-switch');

View File

@ -85,6 +85,11 @@ const I18N_DICT = {
'No candidate could be generated.': 'Es konnte kein Kandidat erzeugt werden.',
'Speech-to-text unavailable: scored by level and audio problems only.': 'Sprache-zu-Text nicht verfügbar: Wertung nur nach Pegel und Audioproblemen.',
'Auto-pick failed': 'Automatische Wahl fehlgeschlagen',
// Conversation LLM endpoint picker
'Custom…': 'Benutzerdefiniert…',
'Custom LLM base URL': 'Eigene LLM-Basis-URL',
'Fetch models': 'Modelle abrufen',
'Add language models under Engines → Language Models': 'Sprachmodelle unter Engines → Language Models hinzufügen',
// Conversation thinking switch
'Thinking': 'Denken',
'on': 'an', 'off': 'aus',

View File

@ -549,7 +549,7 @@ async function loadSettings() {
const refineInp = $('refine-llm-url');
if (refineInp && s.refine_llm_url) refineInp.value = s.refine_llm_url;
const convInp = $('conv-llm-url');
if (convInp && s.conv_llm_url) convInp.value = s.conv_llm_url;
if (convInp && s.conv_llm_url) { convInp.value = s.conv_llm_url; convInp.dispatchEvent(new Event('url-sync')); }
sv('s-tts-stream-url', s.tts_stream_url || '');
sv('s-customvoice-url', s.customvoice_url || 'http://host.docker.internal:8022');
sv('s-nvidia-router-url', s.nvidia_router_url || 'http://host.docker.internal:8090');

View File

@ -17,15 +17,17 @@
</div>
</div>
<div class="engine-setup-col" style="flex:1.5">
<label class="engine-setup-label"><span class="mdi mdi-brain"></span> Language Model <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('llm'); return false;" title="Configure Language Model settings"><span class="mdi mdi-cog-outline"></span></a></label>
<label class="engine-setup-label" for="conv-llm-endpoint"><span class="mdi mdi-brain"></span> Language Model <a href="#" class="engine-setup-link" onclick="navTo('s-llms'); navEnginesCat('llm'); return false;" title="Configure Language Model settings"><span class="mdi mdi-cog-outline"></span></a></label>
<div class="engine-setup-controls">
<input id="conv-llm-url" class="conv-url-inp" type="text" placeholder="http://localhost:11434/v1" spellcheck="false">
<button class="btn-secondary" id="conv-llm-fetch-btn" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
<select id="conv-llm-endpoint" class="conv-llm-pick"><option value="__custom__">Custom…</option></select>
<input id="conv-llm-url" class="conv-url-inp conv-llm-pick" type="text" aria-label="Custom LLM base URL" placeholder="http://localhost:11434/v1" spellcheck="false" hidden>
<button class="btn-secondary conv-llm-pick" id="conv-llm-fetch-btn" title="Fetch models" aria-label="Fetch models"><span class="mdi mdi-refresh"></span></button>
<select id="conv-llm-model-select" aria-label="Language model"><option value="">— fetch models —</option></select>
<label class="conv-thinking" title="Off = faster answers. On = better reasoning but slower, and some models may return an empty reply.">
<input type="checkbox" role="switch" id="conv-thinking-switch" aria-describedby="conv-thinking-help">
<span>Thinking</span> <span id="conv-thinking-state" class="conv-thinking-state">off</span>
</label>
<p id="conv-llm-empty-hint" class="conv-llm-hint" hidden><a href="#" onclick="navTo('s-llms'); navEnginesCat('llm'); return false;">Add language models under Engines → Language Models</a></p>
<span id="conv-thinking-help" class="sr-only">Off = faster answers. On = better reasoning but slower, and some models may return an empty reply.</span>
</div>
</div>

View File

@ -6036,3 +6036,7 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night
.conv-thinking input[type="checkbox"] { width:20px; height:20px; accent-color:var(--accent); cursor:pointer; }
.conv-thinking:focus-within { outline:2px solid var(--accent); outline-offset:2px; }
.conv-thinking-state { font-weight:600; }
.conv-llm-pick { min-height:44px; box-sizing:border-box; }
#conv-llm-fetch-btn { min-width:44px; }
.conv-llm-hint { flex-basis:100%; margin:0; font-size:12px; }
@media (max-width: 640px) { #conv-llm-endpoint, #conv-llm-url { flex-basis:100%; width:100% !important; } }

View File

@ -0,0 +1,76 @@
// @ts-check
// Conversation Playground LLM endpoint picker (all /api routes mocked).
const { test, expect } = require('./_guard');
const CONTAINERS = { containers: [
{ name: 'ollama', label: 'Ollama', role: 'llm', port: 11434, status: 'running' },
{ name: 'vllm', label: 'vLLM', role: 'llm', port: 8000, status: 'exited' },
{ name: 'ghost', label: 'Ghost', role: 'llm', port: 1, status: 'not_found' },
{ name: 'kokoro-tts', label: 'Kokoro', role: 'tts', port: 8880, status: 'running' },
] };
async function boot(page, settings, containers = CONTAINERS) {
/** @type {any[]} */ const patches = [];
/** @type {string[]} */ const modelReqs = [];
await page.route('**/api/**', route => route.request().method() === 'GET' ? route.fulfill({ json: {} }) : route.fulfill({ json: { ok: true } }));
await page.route('**/api/local-containers*', route => route.fulfill({ json: containers }));
await page.route('**/api/conversation/llm-models*', route => { modelReqs.push(route.request().url()); return route.fulfill({ json: { models: ['m1'] } }); });
await page.route('**/api/settings', route => {
if (route.request().method() === 'GET') return route.fulfill({ json: settings });
patches.push(route.request().postDataJSON());
return route.fulfill({ json: { ok: true } });
});
await page.route('**/static/dist/main.min.js*', r => r.abort());
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.evaluate(() => window.navTo('s-conversation'));
return { patches, modelReqs };
}
test('lists configured endpoints, picking one saves it and fetches models', async ({ page }) => {
const { patches, modelReqs } = await boot(page, {});
const pick = page.locator('#conv-llm-endpoint');
await expect(pick.locator('option')).toHaveCount(3);
await expect(pick.locator('option').nth(0)).toContainText('Ollama');
await expect(pick.locator('option').nth(0)).toContainText('http://host.docker.internal:11434/v1');
await expect(pick.locator('option').nth(1)).toContainText('✗');
const box = await pick.boundingBox();
expect(box && box.height).toBeGreaterThanOrEqual(44);
await pick.selectOption({ index: 1 });
await expect(page.locator('#conv-llm-url')).toBeHidden();
await expect.poll(() => patches.some(p => p && p.conv_llm_url === 'http://host.docker.internal:8000/v1')).toBe(true);
await expect.poll(() => modelReqs.some(u => u.includes(encodeURIComponent('http://host.docker.internal:8000/v1')))).toBe(true);
await expect(page.locator('#conv-llm-model-select')).toContainText('m1');
});
test('saved matching URL preselects its entry and hides the input', async ({ page }) => {
await boot(page, { conv_llm_url: 'http://host.docker.internal:11434/v1' });
await expect(page.locator('#conv-llm-endpoint')).toHaveValue('http://host.docker.internal:11434/v1');
await expect(page.locator('#conv-llm-url')).toBeHidden();
});
test('saved custom URL preselects Custom', async ({ page }) => {
await boot(page, { conv_llm_url: 'http://192.168.178.8:17861/v1' });
await expect(page.locator('#conv-llm-endpoint')).toHaveValue('__custom__');
await expect(page.locator('#conv-llm-url')).toBeVisible();
await expect(page.locator('#conv-llm-url')).toHaveValue('http://192.168.178.8:17861/v1');
});
test('Custom… reveals the input right after the select and focuses it', async ({ page }) => {
await boot(page, { conv_llm_url: 'http://host.docker.internal:11434/v1' });
await page.locator('#conv-llm-endpoint').selectOption('__custom__');
const inp = page.getByRole('textbox', { name: 'Custom LLM base URL' });
await expect(inp).toBeFocused();
expect(await page.evaluate(() => document.getElementById('conv-llm-endpoint').nextElementSibling.id)).toBe('conv-llm-url');
});
test('empty list shows only Custom… and the hint linking to Engines', async ({ page }) => {
await boot(page, {}, { containers: [] });
await expect(page.locator('#conv-llm-endpoint option')).toHaveCount(1);
await expect(page.locator('#conv-llm-url')).toBeVisible();
const hint = page.locator('#conv-llm-empty-hint a');
await expect(hint).toBeVisible();
await hint.click();
await expect(page.locator('#s-llms')).toBeVisible();
});