mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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]>
176 lines
6.0 KiB
JavaScript
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();
|
|
},
|
|
};
|
|
}
|