Files
gods-eye-view/src/voice/panel/manualPanel.js
T
manjunath22466andDarren Dignam af461febae 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]>
2026-11-02 20:56:39 +05:30

176 lines
6.0 KiB
JavaScript

const ROW_GRACE_MS = 700;
/** Manual presentation only. Session and action events belong to the host. */
export function createManualVoicePanel({
ui,
card,
getPreviewEnabled,
getCockpitActive = () => false,
copy,
onPresentationIntent,
}) {
if (!ui?.panel || typeof getPreviewEnabled !== 'function')
throw new TypeError(
'A voice panel and Preview preference reader are required.',
);
if (
typeof copy?.previewEmpty !== 'string' ||
typeof copy?.previewDisabled !== 'string'
)
throw new TypeError('Localized Preview messages are required.');
const offs = [];
const tabs = ['previewTab', 'settingsTab'];
const panes = { preview: 'previewPanel', settings: 'settingsPanel' };
let disposed = false;
let open = false;
let view = 'preview';
let graceTimer;
const enabled = () => getPreviewEnabled() === true;
const suppressed = () => view === 'preview' && getCockpitActive() === true;
const holdsFocus = (name) =>
Boolean(ui.element(name)?.contains?.(globalThis.document?.activeElement));
const snapshot = () => ({
view,
panelOpen: !disposed && open,
settingsOpen: !disposed && open && view === 'settings',
previewEnabled: enabled(),
// The card as presented, on a control the page shows.
previewVisible:
!disposed &&
open &&
view === 'preview' &&
!suppressed() &&
enabled() &&
ui.state.card.visible &&
// The error tray takes the card's place.
ui.state.root.status !== 'error' &&
ui.visible('root'),
});
const sync = () => {
if (disposed) return;
const hidePanel = !open || suppressed();
const restoreFocus = hidePanel && holdsFocus('voicePanel');
ui.update('voicePanel', {
hidden: hidePanel,
view,
previewHidden: !open || view !== 'preview' || suppressed(),
settingsHidden: !open || view !== 'settings',
settingsExpanded: open && view === 'settings',
selected: view,
});
card?.setPresented?.(open && view === 'preview' && !suppressed());
const previewOn = enabled();
ui.update('previewEmpty', {
hidden: ui.state.card.visible,
text: () => (previewOn ? copy.previewEmpty : copy.previewDisabled),
});
if (restoreFocus) void ui.focus(['settingsButton', 'chatButton', 'button']);
};
const refuse = (reason) => ({
ok: false,
changed: false,
reason,
...snapshot(),
});
const select = (next, { focus = false, automatic = false } = {}) => {
if (disposed) return refuse('disposed');
if (!['preview', 'settings'].includes(next)) return refuse('unknown-view');
const changed = !open || view !== next;
const transferFocus = changed && holdsFocus(panes[view]);
open = true;
view = next;
// An explicit request restores a minimized panel; automatic reveals do not.
if (!automatic && ui.state.voicePanel.minimized)
ui.update('voicePanel', { minimized: false });
onPresentationIntent?.({ automatic });
sync();
// The view's tab takes focus once it shows, inside a shown panel, or the
// next visible control when the tab is hidden: voice Settings show Back
// instead, and Preview falls to Settings.
if (focus || transferFocus)
void ui.focus(
next === 'preview' ? [tabs[0], tabs[1]] : [tabs[1], 'voiceBack'],
);
return { ok: true, changed, ...snapshot() };
};
const close = (onlyView) => {
if (disposed) return refuse('disposed');
if (onlyView && !['preview', 'settings'].includes(onlyView))
return refuse('unknown-view');
if (onlyView && onlyView !== view)
return { ok: true, changed: false, ...snapshot() };
const changed = open;
open = false;
// A caller's context (Settings opened from chat) ends with the panel.
if (ui.state.voicePanel.minimized || ui.state.voicePanel.context)
ui.update('voicePanel', { minimized: false, context: null });
onPresentationIntent?.();
sync();
return { ok: true, changed, ...snapshot() };
};
const toggleSettings = () =>
open && view === 'settings'
? close('settings')
: select('settings', { focus: true });
// Keep the raised row reachable across pinned trays without intercepting
// their controls. The same lifetime owns the listeners and pending timer.
const endGrace = () => {
clearTimeout(graceTimer);
if (ui.state.root.rowGrace) ui.update('root', { rowGrace: false });
};
offs.push(
ui.on('settings-button', toggleSettings),
// Preview has no visible tab; the Settings tab toggles back to it.
ui.on('tab', (index) =>
index === 1 && open && view === 'settings'
? select('preview', { focus: true })
: select(index === 0 ? 'preview' : 'settings', { focus: true }),
),
ui.on('voice-back', () => select('preview', { focus: true })),
ui.on('tab-keydown', (index, event) => {
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? 1
: ['ArrowLeft', 'ArrowRight'].includes(event.key)
? 1 - index
: null;
if (next === null) return;
event.preventDefault();
select(next === 0 ? 'preview' : 'settings', { focus: true });
}),
ui.on('settings-close', () => close()),
ui.on('panel-keydown', (event) => {
if (event.key !== 'Escape' || !open) return;
event.preventDefault();
// Voice Settings step back to the preview, like chat Settings to chat.
if (view === 'settings' && !ui.state.voicePanel.context)
select('preview', { focus: true });
else close();
}),
ui.on('pointerleave', (event) => {
if (event.pointerType === 'touch') return;
endGrace();
ui.update('root', { rowGrace: true });
graceTimer = setTimeout(endGrace, ROW_GRACE_MS);
}),
ui.on('pointerenter', endGrace),
);
sync();
return {
select,
close,
sync,
snapshot,
destroy() {
if (disposed) return;
open = false;
sync();
disposed = true;
for (const off of offs) off();
endGrace();
},
};
}