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:
parent
8ce8e791b2
commit
dea6286775
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
16
Manual.md
16
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.
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; } }
|
||||
|
||||
76
tests/conversation-llm-picker.spec.js
Normal file
76
tests/conversation-llm-picker.spec.js
Normal 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user