From dff3c625344e830ae72d9f5abb4fd8b2da6e0dcc Mon Sep 17 00:00:00 2001 From: witbrock Date: Mon, 14 Sep 2026 23:22:26 -0400 Subject: [PATCH] Add one-turn model selection to composer actions --- .../web/von_interface/static/js/chatTab.js | 45 +++++++++++--- .../static/js/components/turnModelPicker.js | 60 +++++++++++++++++++ .../web/von_interface/static/styles.css | 20 ++++++- .../web/von_interface/templates/chat_tab.html | 10 ++++ tests/browser/composerControls.cjs | 28 ++++++++- tests/frontend/chatTaskQueue.test.js | 32 +++++++++- tests/frontend/turnModelPicker.test.js | 54 +++++++++++++++++ 7 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 src/frontend/web/von_interface/static/js/components/turnModelPicker.js create mode 100644 tests/frontend/turnModelPicker.test.js diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 322d4d898..d734a306d 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -1,3 +1,4 @@ +import { createTurnModelPicker } from './components/turnModelPicker.js'; import { copyCompactConversationReference, isConversationConceptReference, conversationReferenceMetadata, readConversationReference } from './utils/conversationReference.js'; import { bindConversationRowMenu } from './components/conversationRowMenu.js'; import { initialiseConversationActions } from './components/conversationActions.js'; @@ -186,6 +187,7 @@ const turnEditHistory = new Map(); let historySegmentsShown = 1; let totalHistorySegments = 1; let activeChatSessionId = null; +let turnModelPicker = null; let activeChatSessionName = null; let activeChatSessionOwnerId = null; const conceptQaSessionsByChatSession = new Map(); @@ -25164,6 +25166,7 @@ function setActiveChatSession(sessionId, sessionName, externalConversation = und renderActiveChatSessionFocusChips(activeChatSessionFocus); if (previousSessionId !== activeChatSessionId) { + if (previousSessionId) turnModelPicker?.clear(); setSelectedChatPromptQueueEntryId(null); chatSessionSelectionGeneration += 1; synchroniseLlmExecutionContext({ reason: 'conversation_session_changed' }); @@ -33856,6 +33859,22 @@ export function initializeChatTab() { return; } chatTabInitialised = true; + const turnModelSelect = document.getElementById('turnModelSelect'); + if (turnModelSelect) { + turnModelPicker = createTurnModelPicker({ + select: turnModelSelect, + status: document.getElementById('turnModelStatus'), + clearButton: document.getElementById('clearTurnModelButton'), + loadModels: async provider => { + const response = await fetch(`/api/settings/models/${provider}`, { headers: buildChatFetchHeaders() }); + if (!response.ok) throw new Error('Model catalogue unavailable'); + return response.json(); + } + }); + turnModelSelect.closest('details')?.addEventListener('toggle', event => { + if (event.target.open) void turnModelPicker.load(); + }); + } initialiseConversationCatalogue({ render: (fresh) => { if (fresh === true) reconcileServerConversationPreferences(directConversationRows()); renderChatSessionTabs(sessionTabsCache, activeChatSessionId); }, acceptChats: (rows) => { serverAgentCreatedSessionVisibilityState = null; const selected = sessionTabsCache.find(row => row.session_id === activeChatSessionId); @@ -34330,7 +34349,7 @@ export function initializeChatTab() { } }, onSubmit: async (text, speech) => { - const envelope = buildChatPromptExecutionEnvelope(); + const envelope = buildChatPromptExecutionEnvelope({ modelFields: turnModelPicker?.take() }); envelope.client_context = { ...envelope.client_context, speech_attempt_ids: [speech.attemptId], speech_item_id: speech.itemId }; const row = await queuePromptForLater(text, { sessionId: activeChatSessionId, @@ -35927,7 +35946,8 @@ function buildChatPromptExecutionEnvelope({ fileCopyConceptId = null, imageAttachmentIds = [], turnKind = null, - initiationId = null + initiationId = null, + modelFields = null } = {}) { const userContext = getUserContext() || {}; const localModelRequestFields = buildLocalModelRequestFields( @@ -35941,7 +35961,7 @@ function buildChatPromptExecutionEnvelope({ language: (typeof userContext.language === 'string' && userContext.language.trim()) ? userContext.language.trim() : 'en-NZ', - ...localModelRequestFields, + ...(modelFields || localModelRequestFields), ...(fileCopyConceptId ? { workflow_inputs: { file_copy_concept_id: fileCopyConceptId @@ -37137,7 +37157,7 @@ async function sendQueuedChatPromptEntry(entryId) { return false; } - if (getUnavailableLocalModelPreferenceForChat()) { + if (!nextEntry.executionEnvelope?.model && getUnavailableLocalModelPreferenceForChat()) { nextEntry.syncError = LOCAL_MODEL_UNAVAILABLE_CHAT_MESSAGE; renderChatTaskQueuePanel(); refreshChatSessionTabActivityIndicators(); @@ -37183,6 +37203,7 @@ async function sendQueuedChatPromptEntry(entryId) { clientRequestId: nextEntry.clientRequestId || null, attemptId: nextEntry.attemptId || null, fileCopyConceptId: nextEntry.fileCopyConceptId || null, + executionEnvelope: nextEntry.executionEnvelope, queuedSourceEntryId: cleanEntryId }); return true; @@ -37327,7 +37348,9 @@ async function handleSendPrompt(options = {}) { return; } - if (!observeServerDispatch && getUnavailableLocalModelPreferenceForChat()) { + if (!observeServerDispatch && !options?.executionEnvelope?.model + && !(directComposerSubmission && turnModelPicker?.peek()) + && getUnavailableLocalModelPreferenceForChat()) { if (!fromQueue) { notifyUnavailableLocalModelForChat(); } @@ -37390,7 +37413,8 @@ async function handleSendPrompt(options = {}) { ); const executionEnvelope = buildChatPromptExecutionEnvelope({ fileCopyConceptId: queuedFileCopyConceptId, - imageAttachmentIds: takeImages(targetSessionId) + imageAttachmentIds: takeImages(targetSessionId), + modelFields: directComposerSubmission ? turnModelPicker?.take() : null }); refreshConversationImages(); rememberLastSubmittedUserPrompt(promptRaw); @@ -37462,7 +37486,12 @@ async function handleSendPrompt(options = {}) { fileCopyConceptId: pendingFileCopyConceptId, imageAttachmentIds: options?.executionEnvelope?.image_attachment_ids || (fromQueue ? [] : takeImages(targetSessionId)), turnKind, - initiationId + initiationId, + modelFields: options?.executionEnvelope?.model ? { + model: options.executionEnvelope.model, + model_provider: options.executionEnvelope.model_provider, + model_parameters: options.executionEnvelope.model_parameters || {} + } : (directComposerSubmission && !assistantOpening ? turnModelPicker?.take() : null) }); refreshConversationImages(); const claimedAtMs = Date.parse(String(options?.claimedAt || '')); @@ -42280,3 +42309,5 @@ export function __testOnly_resetExternalConversationBulkImport() { externalConversationBulkImportPanelHidden = false; } export { formatChatTimestamp, showLlmDebugPopup, switchToChatSession, updateHistoryLength }; + +export function __testOnly_setTurnModelPicker(picker) { turnModelPicker = picker; } diff --git a/src/frontend/web/von_interface/static/js/components/turnModelPicker.js b/src/frontend/web/von_interface/static/js/components/turnModelPicker.js new file mode 100644 index 000000000..2e3722e07 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/turnModelPicker.js @@ -0,0 +1,60 @@ +// In-memory composer choice. Only a submitted turn receives these request fields. +export function createTurnModelPicker({ select, status, clearButton, loadModels }) { + let choice = null; + let loaded = false; + let loading = false; + const render = () => { + status.textContent = choice + ? `Next turn only: ${choice.model} (${choice.model_provider}). Default resumes after sending.` + : ''; + status.hidden = !choice; + clearButton.hidden = !choice; + select.value = choice ? JSON.stringify(choice) : ''; + }; + const clear = () => { choice = null; render(); }; + select.addEventListener('change', () => { + choice = select.value ? JSON.parse(select.value) : null; + render(); + }); + clearButton.addEventListener('click', clear); + const load = async () => { + if (loaded || loading) return; + loading = true; + select.disabled = true; + const failures = []; + const providers = ['openai', 'gemini', 'openrouter', 'meta', 'ollama']; + const results = await Promise.allSettled(providers.map(provider => loadModels(provider))); + results.forEach((result, index) => { + const provider = providers[index]; + if (result.status !== 'fulfilled' || !Array.isArray(result.value)) { + failures.push(provider); + return; + } + const group = document.createElement('optgroup'); + group.label = provider; + result.value.forEach(model => { + if (typeof model !== 'string' || !model.trim()) return; + const option = document.createElement('option'); + option.textContent = model; + option.value = JSON.stringify({ model, model_provider: provider }); + group.append(option); + }); + if (group.children.length) select.append(group); + }); + loading = false; + loaded = failures.length === 0; + select.disabled = false; + select.title = failures.length ? `Unavailable: ${failures.join(', ')}. Reopen to retry.` : ''; + render(); + }; + return { + load: async () => { + // Retry unavailable catalogues without duplicating successful options. + if (!loaded && !loading) select.querySelectorAll('optgroup').forEach(group => group.remove()); + await load(); + }, + peek: () => choice ? { ...choice } : null, + take: () => { const fields = choice ? { ...choice, model_parameters: {} } : null; clear(); return fields; }, + clear + }; +} diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 741eb5d58..d09917f0d 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -19001,12 +19001,14 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu white-space: normal; text-align: left; } -.composer-options-section label[for="dictationEngineSelect"] { +.composer-options-section label[for="dictationEngineSelect"], +.composer-options-section label[for="turnModelSelect"] { flex-basis: 100%; color: #475569; font-size: 0.8125rem; } -.composer-options-section #dictationEngineSelect { +.composer-options-section #dictationEngineSelect, +.composer-options-section #turnModelSelect { width: 100%; min-width: 0; padding: 8px; @@ -19021,7 +19023,8 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu box-sizing: border-box; } .composer-options-section .compact-speech-help, -.composer-options-section #imageAttachmentHelp { +.composer-options-section #imageAttachmentHelp, +.composer-options-section #turnModelHelp { display: block; flex-basis: 100%; margin: 0; @@ -19043,3 +19046,14 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu } .composer-options-section { padding: 12px; } } + +.chat-composer > #turnModelStatus { + grid-column: 1 / -1; + flex-basis: 100%; + margin: 0; + font-size: 0.875rem; + color: #334155; + overflow-wrap: anywhere; +} +.chat-composer > #clearTurnModelButton { grid-column: 1 / -1; } +#turnModelStatus[hidden], #clearTurnModelButton[hidden] { display: none !important; } diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index 73b6c0495..f0c087f3d 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -248,6 +248,14 @@

More actions
+
+

Model for next turn

+ + +

Choose a stronger or faster model for your next message only. Sending or queueing it restores the default for future turns.

+

Voice & dictation

@@ -299,6 +307,8 @@

Conversation

+ +

diff --git a/tests/browser/composerControls.cjs b/tests/browser/composerControls.cjs index 19c325df5..303cd82be 100644 --- a/tests/browser/composerControls.cjs +++ b/tests/browser/composerControls.cjs @@ -116,7 +116,7 @@ async function bounds(page, selector) { await page.keyboard.press('Enter'); await expect(page.locator('.chat-composer-more-actions')).toHaveAttribute('open', ''); await page.keyboard.press('Tab'); - if (profile.desktop) await expect(page.getByRole('button', { name: 'Start voice', exact: true })).toBeFocused(); + if (profile.desktop) await expect(page.locator('#turnModelSelect')).toBeFocused(); await expect(page.getByRole('button', { name: 'Upload File', exact: true })).toBeVisible(); if (profile.desktop) { const panel = await bounds(page, '.chat-composer-more-actions>.button-row'); @@ -124,6 +124,32 @@ async function bounds(page, selector) { assert.equal((await bounds(page, '.chat-composer')).height, composer.height, 'menu must not expand desktop composer'); } if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-menu.png`), fullPage: true }); + await page.evaluate(async () => { + const { createTurnModelPicker } = await import('/static/js/components/turnModelPicker.js'); + window.fixtureModelPicker = createTurnModelPicker({ + select: document.querySelector('#turnModelSelect'), status: document.querySelector('#turnModelStatus'), + clearButton: document.querySelector('#clearTurnModelButton'), + loadModels: async provider => provider === 'openai' ? ['gpt-6-astra', 'gpt-5.6-luna'] : [] + }); + await window.fixtureModelPicker.load(); + }); + for (const model of ['gpt-6-astra', 'gpt-5.6-luna']) { + await page.locator('#turnModelSelect').selectOption({ label: model }); + // The temporary state must remain visible with the menu closed. + await summary.click(); + await expect(page.locator('#turnModelStatus')).toBeVisible(); + await expect(page.locator('#turnModelStatus')).toContainText(`Next turn only: ${model}`); + const notice = await bounds(page, '#turnModelStatus'); + assert(notice.width >= Math.min(280, profile.width - 40), 'temporary notice has a readable full row'); + assert(notice.height < 100, 'temporary notice remains compact'); + await page.locator('#clearTurnModelButton').click({ trial: true }); + if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-${model}-temporary.png`) }); + const request = await page.evaluate(() => window.fixtureModelPicker.take()); + assert.equal(request.model, model); + assert.equal(await page.evaluate(() => window.fixtureModelPicker.take()), null); + await expect(page.locator('#turnModelStatus')).toBeHidden(); + await summary.click(); + } // Each group remains reachable within the scrolling disclosure. const options = page.locator('.composer-options-panel'); for (const id of ['dictationEngineSelect', 'ttsToggle', 'pasteImageButton', 'uploadFileButton', 'annotationToggle', 'resetButton']) { diff --git a/tests/frontend/chatTaskQueue.test.js b/tests/frontend/chatTaskQueue.test.js index f5d0a2ce6..8cfd0f699 100644 --- a/tests/frontend/chatTaskQueue.test.js +++ b/tests/frontend/chatTaskQueue.test.js @@ -69,6 +69,7 @@ describe('chat task queue', () => { try { const chatTab = require(chatTabModulePath); if (typeof chatTab.__testOnly_resetChatRequestState === 'function') { + chatTab.__testOnly_setTurnModelPicker(null); chatTab.__testOnly_resetChatRequestState(); } } catch (_) { @@ -118,7 +119,7 @@ describe('chat task queue', () => { expect(dictationStatus.textContent).toBe(''); }); - test('sends the stored Gemini choice as a bare model with its exact provider', async () => { + test('uses a temporary model once, restores default parameters and preserves queued model choices', async () => { const { getUserContext } = require('../../src/frontend/web/von_interface/static/js/apiService.js'); const { sendMessage } = require(chatTabModulePath); getUserContext.mockReturnValue({ @@ -177,6 +178,29 @@ describe('chat task queue', () => { model_parameters: { reasoning_effort: 'high' } }); expect(generateBody.model).not.toContain('gemini:'); + const { createTurnModelPicker } = require('../../src/frontend/web/von_interface/static/js/components/turnModelPicker.js'); + document.body.insertAdjacentHTML('beforeend', '

'); + const select = document.getElementById('temporaryModel'); + const picker = createTurnModelPicker({ select, + status: document.getElementById('temporaryStatus'), + clearButton: document.getElementById('temporaryClear'), + loadModels: async provider => provider === 'openai' ? ['gpt-6-astra'] : [] }); + await picker.load(); + require(chatTabModulePath).__testOnly_setTurnModelPicker(picker); + select.value = JSON.stringify({ model: 'gpt-6-astra', model_provider: 'openai' }); + select.dispatchEvent(new Event('change')); + document.getElementById('promptInput').value = 'Use stronger model once'; + await sendMessage(); + expect(generateBody).toMatchObject({ model: 'gpt-6-astra', model_provider: 'openai', model_parameters: {} }); + expect(picker.peek()).toBeNull(); + document.getElementById('promptInput').value = 'Use default again'; + await sendMessage(); + expect(generateBody).toMatchObject({ model: 'gemini-3.7-flash', model_provider: 'gemini', model_parameters: { reasoning_effort: 'high' } }); + // A retained queue choice must survive later default changes. + await sendMessage({ fromQueue: true, promptOverride: 'Queued stronger turn', sessionId: 'session-1', + executionEnvelope: { model: 'gpt-6-astra', model_provider: 'openai', model_parameters: {} } }); + expect(generateBody).toMatchObject({ model: 'gpt-6-astra', model_provider: 'openai', model_parameters: {} }); + require(chatTabModulePath).__testOnly_setTurnModelPicker(null); } finally { localStorage.removeItem('von:localModelPreference'); window.scrollTo = originalScrollTo; @@ -267,8 +291,14 @@ describe('chat task queue', () => { expect(generateBodies).toHaveLength(1); expect(document.getElementById('sendButton').textContent).toBe('Queue Prompt'); + const temporaryChoice = { model: 'gpt-6-astra', model_provider: 'openai', model_parameters: {} }; + const picker = { peek: () => temporaryChoice, take: jest.fn(() => temporaryChoice), clear: jest.fn() }; + require(chatTabModulePath).__testOnly_setTurnModelPicker(picker); promptInput.value = 'Second draft'; await sendMessage(); + expect(picker.take).toHaveBeenCalledTimes(1); + expect(queueBodies[1].execution_envelope).toMatchObject(temporaryChoice); + require(chatTabModulePath).__testOnly_setTurnModelPicker(null); const queuedEditor = document.querySelector('.chat-task-queue-edit'); expect(queuedEditor).toBeTruthy(); diff --git a/tests/frontend/turnModelPicker.test.js b/tests/frontend/turnModelPicker.test.js new file mode 100644 index 000000000..2652e233b --- /dev/null +++ b/tests/frontend/turnModelPicker.test.js @@ -0,0 +1,54 @@ +/** @jest-environment jsdom */ +import { createTurnModelPicker } from '../../src/frontend/web/von_interface/static/js/components/turnModelPicker.js'; + +describe('temporary turn model picker', () => { + let picker, select, status, clearButton; + beforeEach(() => { + document.body.innerHTML = '

'; + select = document.querySelector('select'); + status = document.querySelector('p'); + clearButton = document.querySelector('button'); + }); + async function setup(loadModels = async provider => provider === 'openai' ? ['gpt-6-astra', 'gpt-5.6-luna'] : []) { + picker = createTurnModelPicker({ select, status, clearButton, loadModels }); + await picker.load(); + } + function choose(model) { + select.value = JSON.stringify({ model, model_provider: 'openai' }); + select.dispatchEvent(new Event('change')); + } + test.each(['gpt-6-astra', 'gpt-5.6-luna'])('consumes %s for exactly one turn without writing preferences', async model => { + localStorage.setItem('default-model', 'unchanged'); + const write = jest.spyOn(Storage.prototype, 'setItem'); + await setup(); + choose(model); + expect(status.hidden).toBe(false); + expect(status.textContent).toContain(`Next turn only: ${model}`); + const submitted = picker.take(); + expect(submitted).toEqual({ model, model_provider: 'openai', model_parameters: {} }); + expect(picker.take()).toBeNull(); + expect(status.hidden).toBe(true); + expect(select.value).toBe(''); + expect(write).not.toHaveBeenCalled(); + write.mockRestore(); + }); + test('cancel does not submit an override', async () => { + await setup(); choose('gpt-6-astra'); clearButton.click(); + expect(picker.take()).toBeNull(); + }); + test('catalogue failure leaves other providers usable and permits retry', async () => { + let unavailable = true; + const loadModels = jest.fn(async provider => { + if (provider === 'gemini' && unavailable) throw new Error('offline'); + return provider === 'openai' ? ['gpt-6-astra'] : []; + }); + await setup(loadModels); + expect(select.title).toContain('gemini'); + choose('gpt-6-astra'); + expect(picker.peek().model).toBe('gpt-6-astra'); + unavailable = false; + await picker.load(); + expect(select.options.length).toBe(2); + expect(select.title).toBe(''); + }); +});