Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -35927,7 +35946,8 @@ function buildChatPromptExecutionEnvelope({
fileCopyConceptId = null,
imageAttachmentIds = [],
turnKind = null,
initiationId = null
initiationId = null,
modelFields = null
} = {}) {
const userContext = getUserContext() || {};
const localModelRequestFields = buildLocalModelRequestFields(
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -37327,7 +37348,9 @@ async function handleSendPrompt(options = {}) {
return;
}

if (!observeServerDispatch && getUnavailableLocalModelPreferenceForChat()) {
if (!observeServerDispatch && !options?.executionEnvelope?.model
&& !(directComposerSubmission && turnModelPicker?.peek())
&& getUnavailableLocalModelPreferenceForChat()) {
if (!fromQueue) {
notifyUnavailableLocalModelForChat();
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 || ''));
Expand Down Expand Up @@ -42280,3 +42309,5 @@ export function __testOnly_resetExternalConversationBulkImport() {
externalConversationBulkImportPanelHidden = false;
}
export { formatChatTimestamp, showLlmDebugPopup, switchToChatSession, updateHistoryLength };

export function __testOnly_setTurnModelPicker(picker) { turnModelPicker = picker; }
Original file line number Diff line number Diff line change
@@ -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
};
}
20 changes: 17 additions & 3 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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; }
10 changes: 10 additions & 0 deletions src/frontend/web/von_interface/templates/chat_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,14 @@ <h3 id="conversationReferenceInspectorTitle" class="chat-reference-inspector-tit
<details class="chat-composer-more-actions">
<summary title="More actions"><span class="composer-more-label">More actions</span><span class="composer-more-icon" aria-hidden="true">⋯</span><span class="composer-add-icon" aria-hidden="true">+</span></summary>
<div class="button-row composer-options-panel">
<section class="composer-options-section" aria-labelledby="composerModelHeading">
<h3 id="composerModelHeading">Model for next turn</h3>
<label for="turnModelSelect">Temporary model</label>
<select id="turnModelSelect" aria-describedby="turnModelHelp turnModelStatus">
<option value="">Use default model</option>
</select>
<p id="turnModelHelp">Choose a stronger or faster model for your next message only. Sending or queueing it restores the default for future turns.</p>
</section>
<section class="composer-options-section" aria-labelledby="composerSpeechHeading">
<h3 id="composerSpeechHeading">Voice &amp; dictation</h3>
<div class="composer-speech-actions">
Expand Down Expand Up @@ -299,6 +307,8 @@ <h3 id="composerConversationHeading">Conversation</h3>
</div>
</details>
</div>
<p id="turnModelStatus" role="status" hidden></p>
<button id="clearTurnModelButton" class="btn btn-secondary" type="button" hidden>Use default model</button>
<p id="voiceConversationStatus" class="speech-settings-note" role="status" aria-live="polite"></p>
<p id="dictationStatus" class="speech-settings-note" role="status" aria-live="polite"></p>
</div>
Expand Down
28 changes: 27 additions & 1 deletion tests/browser/composerControls.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,40 @@ 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');
assert(panel.y >= 0 && panel.x >= 0 && panel.x + panel.width <= profile.width, JSON.stringify({ panel, composer }));
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']) {
Expand Down
32 changes: 31 additions & 1 deletion tests/frontend/chatTaskQueue.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (_) {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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', '<select id="temporaryModel"><option value="">Default</option></select><p id="temporaryStatus"></p><button id="temporaryClear"></button>');
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;
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading