feat(voice): add typed AI chat beside voice

Chat sits next to the mic in the command dock. Replies are silent and never
request the microphone; the first message connects with the selected provider
and runs the same map actions as voice through each provider's text API
(OpenAI OPENAI_CHAT_MODEL, Gemini GEMINI_CHAT_MODEL via generateContent, which
never starts Live). Only one mode runs at a time.

- Chat panel rendered by the voice control presenter: conversation, recent and
  quick-command suggestions, Up/Down history, attachments (map-layer
  references or small UTF-8 text files, sent only with the message)
- Chat and voice panels sit above the dock or dock into the right rail's
  column; minimized bars sit centred above the dock, and height animates on
  swaps
- A centred panel steps down to its bar for the Location and Visual Presets
  trays without moving focus, rests above an open tray, and docks right while
  expanded over one; it returns to the centre once the trays close
- Minimized voice previews the last sentence from each side; the empty Voice
  Preview suggests things to say
- Settings opened from chat keep chat's frame and show the shared AI provider
  and quick commands; voice Settings show Back to voice
- Closing chat ends a typed session; the voice panel's close turns voice off
- The dock widens its voice control for the chat button and restyles its
  wings, which show icons only at 900px and below; assistant panels share the
  left panels' glass, and Cyber keeps its own surfaces
- Server text-chat handlers with body, time and per-IP limits
  (GEV_RATELIMIT_*_CHAT_PER_MIN), documented in SECURITY.md; tests and all
  eleven locales

Co-authored-by: Darren Dignam <[email protected]>
This commit is contained in:
manjunath22466andDarren Dignam committed 2026-11-02 20:56:39 +05:30
1 parent 720260af58
commit af461febae
57 files changed
+6500 -63

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { bindVoiceMiniPreview, lastSentence } from './voiceMiniPreview.js';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
test('lastSentence keeps only the final sentence across scripts', () => {
assert.equal(
lastSentence('Flying to Tokyo. Showing flights now.', 'en'),
'Showing flights now.',
);
assert.equal(
lastSentence(' One sentence only ', 'en'),
'One sentence only',
);
assert.equal(
lastSentence('東京へ移動します。便を表示します。', 'ja'),
'便を表示します。',
);
assert.equal(lastSentence('', 'en'), '');
assert.equal(lastSentence(null, 'en'), '');
});
function fixture({ textOnly = false } = {}) {
const ui = createTestVoiceUi({ panel: true });
const listeners = new Set();
const session = {
adapter: { getStartOptions: () => ({ textOnly }) },
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
const emit = (event) => listeners.forEach((fn) => fn(event));
const unbind = bindVoiceMiniPreview({ ui, session });
return { ui, emit, unbind, listeners };
}
test('voice preview shows the last user and assistant sentence', () => {
const f = fixture();
const mini = () => f.ui.state.mini;
assert.deepEqual(mini(), { user: '', assistant: '' });
assert.equal(f.ui.state.root.voiceExchange, false);
f.emit({ type: 'transcript', role: 'user', text: 'Hi. Show live flights.' });
assert.equal(mini().user, 'Show live flights.');
assert.equal(f.ui.state.root.voiceExchange, true);
f.emit({
type: 'transcript',
role: 'assistant',
text: 'Showing flights. ',
responseId: 'r1',
});
f.emit({
type: 'transcript',
role: 'assistant',
text: 'There are 214',
responseId: 'r1',
});
assert.equal(mini().assistant, 'There are 214');
f.emit({
type: 'transcript',
role: 'assistant',
text: 'Showing flights. There are 214 aircraft.',
responseId: 'r1',
final: true,
});
assert.equal(mini().assistant, 'There are 214 aircraft.');
f.emit({
type: 'transcript',
role: 'assistant',
text: 'Done.',
responseId: 'r2',
});
assert.equal(mini().assistant, 'Done.');
f.emit({ type: 'state', state: 'connecting' });
assert.deepEqual(mini(), { user: '', assistant: '' });
assert.equal(f.ui.state.root.voiceExchange, false);
f.unbind();
assert.equal(f.listeners.size, 0);
});
test('typed chat turns do not reach the voice preview', () => {
const f = fixture({ textOnly: true });
f.emit({ type: 'transcript', role: 'user', text: 'Typed question.' });
f.emit({
type: 'transcript',
role: 'assistant',
text: 'Typed reply.',
final: true,
});
assert.deepEqual(f.ui.state.mini, { user: '', assistant: '' });
assert.equal(f.ui.state.root.voiceExchange, false);
});