diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 322d4d89..d734a306 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 00000000..2e3722e0 --- /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 741eb5d5..d09917f0 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 73b6c049..f0c087f3 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 @@