feat(voice): typed command box, on-screen replies and help answers for the local provider

This commit is contained in:
TD 2026-09-09 19:19:45 +02:00
parent e39fba5e21
commit 1dc6fca642
3 changed files with 89 additions and 3 deletions

View File

@ -224,7 +224,9 @@ function sendJson(res, status, payload) {
export function installLocalVoiceMiddleware(middlewares, { tools, instructions, env = process.env }) {
const localInstructions = `${instructions}\n`
+ 'You are running on a local/private model, not OpenAI. Reply in the language the operator speaks (Spanish when they speak Spanish). '
+ 'Keep spoken replies to one short sentence. When a tool is needed, call it; after tool results, confirm in one sentence.';
+ 'Keep spoken replies to one short sentence. When a tool is needed, call it; after tool results, confirm in one sentence. '
+ 'If the operator asks what you can do, what to say, or how to zoom / rotate / orbit / follow / enter the cockpit / change layers or styles, '
+ 'answer without calling a tool: a short list (max 8 lines) of example commands drawn from your tools, in their language.';
middlewares.use('/api/local-voice/status', (req, res) => {
const config = resolveLocalVoiceProvider(env);

View File

@ -48,6 +48,10 @@ export class LocalVoiceController {
this.spaceKeyHeld = false;
this.pushToTalkKeyHeld = false;
this.lastError = null;
this.panel = createLocalPanel(this.ui?.root, {
onSubmit: (text) => this.sendTextCommand(text),
placeholder: this.lang.toLowerCase().startsWith('es') ? 'Escribe una orden · «?» = ayuda' : "Type a command · '?' for help",
});
this.setStatus('idle');
}
@ -123,6 +127,7 @@ export class LocalVoiceController {
}
stop({ removeUi = false } = {}) {
if (removeUi) this.panel?.root?.remove();
this.active = false;
this.stopRecognizer();
try { window.speechSynthesis?.cancel(); } catch { /* ignore */ }
@ -131,12 +136,24 @@ export class LocalVoiceController {
}
sendTextCommand(text) {
const clean = String(text || '').trim();
let clean = String(text || '').trim();
if (!clean) return false;
if (clean === '?' || clean === 'help' || clean === 'ayuda') {
clean = this.lang.toLowerCase().startsWith('es')
? '¿Qué puedo decirte? Resume en pocas líneas las órdenes que entiendes: navegar, zoom, girar/orbitar, seguir aviones o barcos, cabina, capas, estilos, radio.'
: 'What can I say? Summarize in a few lines the commands you understand: navigate, zoom, orbit/rotate, follow aircraft or ships, cockpit, layers, styles, radio.';
}
this.handleUtterance(clean, { typed: true });
return true;
}
showReply(text, { error = false } = {}) {
if (!this.panel?.reply) return;
this.panel.reply.hidden = !text;
this.panel.reply.textContent = text || '';
this.panel.reply.dataset.error = error ? 'true' : 'false';
}
providerLabel() {
if (this.provider === 'anthropic') return 'CLAUDE';
if (this.provider === 'ollama') return (this.model || 'LOCAL').toUpperCase().slice(0, 12);
@ -171,10 +188,12 @@ export class LocalVoiceController {
}
const reply = String(turn.text || '').trim() || (rounds ? 'Done.' : '');
this.pushHistory({ role: 'assistant', content: reply, toolCalls: [] });
if (reply) await this.speak(reply);
this.showReply(reply);
if (reply && this.active) await this.speak(reply);
this.setStatus(this.active ? 'listening' : 'idle', this.active ? `${this.providerLabel()} · ${reply.slice(0, 70)}` : undefined);
} catch (error) {
this.lastError = String(error?.message || error);
this.showReply(this.lastError, { error: true });
this.setStatus(this.active ? 'listening' : 'idle', `${this.providerLabel()} · ${this.lastError.slice(0, 80)}`);
if (typed) console.warn('[local-voice]', this.lastError);
} finally {
@ -296,3 +315,32 @@ export class LocalVoiceController {
return { provider: this.provider, model: this.model, lang: this.lang, active: this.active, busy: this.busy, history: this.history.length, lastError: this.lastError };
}
}
/**
* Typed command box + last-reply line under the mic control. Voice is optional:
* the same turn loop runs from the keyboard (car passengers, quiet rooms,
* browsers without SpeechRecognition).
*/
function createLocalPanel(root, { onSubmit, placeholder }) {
if (!root || typeof document === 'undefined') return null;
root.querySelector('.gev-local-voice')?.remove();
const panel = document.createElement('div');
panel.className = 'gev-local-voice';
panel.innerHTML = `
<form class="gev-local-voice-form" autocomplete="off">
<input class="gev-local-voice-input" type="text" spellcheck="false" aria-label="Command" />
</form>
<div class="gev-local-voice-reply" role="status" aria-live="polite" hidden></div>`;
const form = panel.querySelector('form');
const input = panel.querySelector('input');
input.placeholder = placeholder;
form.addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value;
input.value = '';
onSubmit(text);
});
input.addEventListener('keydown', (event) => event.stopPropagation()); // keep app hotkeys (Space, 1-7) out of the box
root.appendChild(panel);
return { root: panel, input, reply: panel.querySelector('.gev-local-voice-reply') };
}

View File

@ -9520,3 +9520,39 @@ body.scene-playback-mode #key-setup {
border-color: rgba(255, 170, 150, 0.65);
outline: none;
}
/* Local voice provider: typed command box + last reply (src/voice/localVoice.js) */
.gev-local-voice {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
width: 100%;
}
.gev-local-voice-input {
width: 100%;
box-sizing: border-box;
font: inherit;
font-size: 12px;
color: inherit;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 4px;
padding: 4px 6px;
outline: none;
}
.gev-local-voice-input:focus {
border-color: rgba(255, 255, 255, 0.6);
}
.gev-local-voice-reply {
font-size: 12px;
line-height: 1.35;
max-height: 9.5em;
overflow-y: auto;
white-space: pre-wrap;
opacity: 0.9;
padding: 2px 2px 0;
}
.gev-local-voice-reply[data-error='true'] {
color: #ff8a80;
}