diff --git a/CHANGELOG.md b/CHANGELOG.md index 0696a4c..7ecb982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. --- diff --git a/Manual.md b/Manual.md index 43d2f95..c9caa06 100644 --- a/Manual.md +++ b/Manual.md @@ -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. diff --git a/static/js/conversation.js b/static/js/conversation.js index b102ac4..e8f1a72 100644 --- a/static/js/conversation.js +++ b/static/js/conversation.js @@ -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 ``; + }).join('') + ``; + 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'); diff --git a/static/js/i18n.js b/static/js/i18n.js index 06661ba..07474e3 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -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', diff --git a/static/js/settings.js b/static/js/settings.js index 3b98b47..0c765c8 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -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'); diff --git a/static/sections/s-conversation.html b/static/sections/s-conversation.html index 336959d..d3aba3b 100644 --- a/static/sections/s-conversation.html +++ b/static/sections/s-conversation.html @@ -17,15 +17,17 @@
- +
- - + + + + Off = faster answers. On = better reasoning but slower, and some models may return an empty reply.
diff --git a/static/style.css b/static/style.css index 5b7b731..b251807 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } } diff --git a/tests/conversation-llm-picker.spec.js b/tests/conversation-llm-picker.spec.js new file mode 100644 index 0000000..783bbed --- /dev/null +++ b/tests/conversation-llm-picker.spec.js @@ -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(); +});