mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(voice): render the voice control from a presenter
createVoiceControl now returns a presenter instead of building the control with innerHTML: the session, cost, input, card and panel owners write its state by part and listen for interactions, and VoiceControl.vue renders it in the command dock, between the location bar and the visual presets as before. Focus moves wait for the render; the waveform's bars publish apart from the control and only when they change; the minutes field keeps a draft until its owner writes it. Owner tests use a real presenter on their own store, and view tests render the control.
This commit is contained in:
1 parent
2ffd7a4afd
commit
1de4f635ca
47 files changed
+1691
-1645
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* The voice control as the command dock renders it. The voice control
|
||||
* presenter (src/voice/control.js) holds the control's state for the session
|
||||
* owners and publishes it here; the view sends clicks, keys and form changes
|
||||
* back to the connected presenter.
|
||||
*/
|
||||
export const useVoiceStore = defineStore('gev-voice', () => {
|
||||
/** The presented control; null while no voice session owns one. */
|
||||
const view = shallowRef(null);
|
||||
|
||||
/** @param {?object} next A fresh presentation; every publish re-renders. */
|
||||
function publish(next) {
|
||||
view.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
/** The presenter the view calls, or null while none is wired. */
|
||||
const controls = shallowRef(null);
|
||||
|
||||
/** @param {?object} next The presenter, or null to disconnect. */
|
||||
function connect(next) {
|
||||
controls.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* The waveform's bars, apart from the view: they change every animation
|
||||
* frame while voice is live, and only the visualizer renders them.
|
||||
*/
|
||||
const bars = shallowRef(null);
|
||||
|
||||
/** @param {?Array<{level: string, opacity: string}>} next */
|
||||
function publishBars(next) {
|
||||
bars.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
return { view, publish, controls, connect, bars, publishBars };
|
||||
});
|
||||
@@ -3,8 +3,11 @@ import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import { useDisplayStore } from '../stores/display.js';
|
||||
import { computed } from 'vue';
|
||||
import { useLocationStore } from '../stores/location.js';
|
||||
import { useVoiceStore } from '../stores/voice.js';
|
||||
import PhoneTabBar from './panels/PhoneTabBar.vue';
|
||||
import VoiceControl from './panels/VoiceControl.vue';
|
||||
|
||||
// VisualSettings publishes the active style and the map source controls
|
||||
// their chip row; until they do, the dock shows its initial markup. The
|
||||
@@ -14,6 +17,21 @@ const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore());
|
||||
// and the search field keeps their listeners.
|
||||
const { view: loc, controls: locationControls } = storeToRefs(useLocationStore());
|
||||
|
||||
// The voice control, while a voice session presents one. It sits between the
|
||||
// location bar and the visual presets; the trays keep their elements when the
|
||||
// order changes.
|
||||
const { view: voice } = storeToRefs(useVoiceStore());
|
||||
// Once voice has taken its place, the location bar stays first.
|
||||
let voiceShown = false;
|
||||
const dockOrder = computed(() => {
|
||||
if (voice.value) voiceShown = true;
|
||||
return voice.value
|
||||
? ['location', 'voice', 'control']
|
||||
: voiceShown
|
||||
? ['location', 'control']
|
||||
: ['control', 'location'];
|
||||
});
|
||||
|
||||
function selectMapSource(chip) {
|
||||
if (!chip.available) return;
|
||||
void mapSourceControls.value?.select(chip.id).catch(() => {});
|
||||
@@ -21,10 +39,11 @@ function selectMapSource(chip) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
|
||||
<!-- Unified bottom command dock: locations, voice, visual presets -->
|
||||
<div id="command-dock" class="ui-scroll" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label>
|
||||
<template v-for="slot in dockOrder" :key="slot">
|
||||
<!-- Control panel -->
|
||||
<div id="control-panel" class="panel-collapsible collapsed">
|
||||
<div v-if="slot === 'control'" id="control-panel" class="panel-collapsible collapsed">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="panel-inner">
|
||||
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
|
||||
@@ -82,8 +101,10 @@ function selectMapSource(chip) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VoiceControl v-else-if="slot === 'voice'" :key="voice.id" :view="voice" />
|
||||
|
||||
<!-- Location bar -->
|
||||
<div id="location-bar" class="panel-collapsible collapsed">
|
||||
<div v-else-if="slot === 'location'" id="location-bar" class="panel-collapsible collapsed">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="location-inner">
|
||||
<div class="location-toolbar">
|
||||
@@ -113,6 +134,7 @@ function selectMapSource(chip) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<PhoneTabBar />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { ASSISTANT_NAME } from '../../../voice/control.js';
|
||||
|
||||
// Captions, plan and result, as VoiceCardControls (src/voice/voiceCard.js)
|
||||
// presents them. Captions update silently; the polite live region carries
|
||||
// final replies and result titles only.
|
||||
defineProps({ card: { type: Object, required: true } });
|
||||
const emit = defineEmits(['dismiss']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="gev-voice-card" class="gev-voice-card ui-scroll" :hidden="!card.visible" :data-busy="card.busy == null ? undefined : String(card.busy)" :aria-label="t('Voice assistant')">
|
||||
<div class="gev-voice-card-header">
|
||||
<span class="gev-voice-card-kicker">{{ t('VOICE') }}</span>
|
||||
<span id="gev-voice-card-phase" class="gev-voice-card-phase">{{ card.phase }}</span>
|
||||
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" :aria-label="t('Dismiss voice card')" @click="emit('dismiss')">×</button>
|
||||
</div>
|
||||
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" :hidden="!card.user.visible"><span id="gev-voice-card-user-who" class="gev-voice-card-who">{{ card.user.label }}</span><span id="gev-voice-card-user-text" class="gev-voice-card-text">{{ card.user.text }}</span></p>
|
||||
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" :hidden="!card.pointer.visible"><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">{{ card.pointer.kind }}</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label">{{ card.pointer.text }}</span></p>
|
||||
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" :hidden="!card.assistant.visible"><span class="gev-voice-card-who">{{ ASSISTANT_NAME }}</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text">{{ card.assistant.text }}</span></p>
|
||||
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" :aria-label="t('Plan')" :hidden="card.steps.length === 0"><li v-for="(step, index) in card.steps" :key="step.key ?? index" class="gev-voice-card-step" :data-status="step.status"><span class="gev-voice-card-step-label">{{ step.label }}</span><span class="gev-voice-card-sr">{{ step.statusText ? `, ${step.statusText}` : '' }}</span><span v-if="step.detail" class="gev-voice-card-step-detail">{{ step.detail }}</span></li></ol>
|
||||
<div id="gev-voice-card-result" class="gev-voice-card-result" :hidden="!card.result.visible">
|
||||
<div id="gev-voice-card-result-title" class="gev-voice-card-result-title">{{ card.result.title }}</div>
|
||||
<div id="gev-voice-card-chips" class="gev-voice-card-chips" :hidden="card.result.chips.length === 0"><span v-for="(chip, index) in card.result.chips" :key="index" class="gev-voice-card-chip" :data-tone="chip">{{ chip }}</span></div>
|
||||
<ul id="gev-voice-card-lines" class="gev-voice-card-lines"><li v-for="(line, index) in card.result.lines" :key="index">{{ line }}</li></ul>
|
||||
<ol id="gev-voice-card-referents" class="gev-voice-card-referents" :aria-label="t('Referenced items')" :hidden="card.result.referents.length === 0"><li v-for="(ref, index) in card.result.referents" :key="index" :value="ref.n">{{ ref.label }}</li></ol>
|
||||
<details id="gev-voice-card-notes" class="gev-voice-card-notes" :hidden="card.result.notes.length === 0">
|
||||
<summary>{{ t('NOTES & SOURCES') }}</summary>
|
||||
<ul id="gev-voice-card-note-list" class="gev-voice-card-lines"><li v-for="(note, index) in card.result.notes" :key="index">{{ note }}</li></ul>
|
||||
</details>
|
||||
</div>
|
||||
<div id="gev-voice-card-live" class="gev-voice-card-sr" aria-live="polite" aria-atomic="true">{{ card.announce }}</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { vClassToggle } from '../../rail/classToggle.js';
|
||||
import { useVoiceStore } from '../../stores/voice.js';
|
||||
import VoiceCard from './VoiceCard.vue';
|
||||
import VoiceVisualizer from './VoiceVisualizer.vue';
|
||||
|
||||
// The voice control in the command dock, as its presenter
|
||||
// (src/voice/control.js) holds it. The session owners write the presenter;
|
||||
// this renders it and reports what the person does.
|
||||
defineProps({ view: { type: Object, required: true } });
|
||||
const { controls } = storeToRefs(useVoiceStore());
|
||||
const emit = (name, ...args) => controls.value?.emit(name, ...args);
|
||||
|
||||
// A value the owner may restore after a refused change; written on every
|
||||
// render, as the owner's own writes were.
|
||||
const vChecked = {
|
||||
mounted(el, { value }) {
|
||||
el.checked = value;
|
||||
},
|
||||
updated(el, { value }) {
|
||||
if (el.checked !== value) el.checked = value;
|
||||
},
|
||||
};
|
||||
// The minutes field holds a draft until it changes: write it only when its
|
||||
// owner does, not on every render (the waveform renders each frame).
|
||||
const vOwnedValue = {
|
||||
mounted(el, { value }) {
|
||||
el.value = value.text;
|
||||
},
|
||||
updated(el, { value, oldValue }) {
|
||||
if (value.revision !== oldValue?.revision && el.value !== value.text)
|
||||
el.value = value.text;
|
||||
},
|
||||
};
|
||||
const optional = (value) => (value == null ? undefined : String(value));
|
||||
// The root's machine-state attributes, which the dock and theme CSS select on.
|
||||
const rootData = (root) => ({
|
||||
'data-status': root.status,
|
||||
'data-speaker': root.speaker,
|
||||
'data-provider': optional(root.provider),
|
||||
'data-provider-choice': optional(root.providerChoice),
|
||||
'data-push-to-talk': optional(root.pushToTalk),
|
||||
'data-microphone': optional(root.microphone),
|
||||
'data-row-grace': root.rowGrace ? 'true' : undefined,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="gev-voice-control" v-bind="rootData(view.root)" v-class-toggle="{ 'error-dismissed': view.root.errorDismissed }" @pointerleave="emit('pointerleave', $event)" @pointerenter="emit('pointerenter', $event)">
|
||||
<label id="gev-voice-provider-field" class="gev-voice-provider-field" :hidden="view.provider.hidden">
|
||||
<span>{{ t('Voice provider') }}</span>
|
||||
<select id="gev-voice-provider" class="gev-field" :aria-label="t('Voice provider')" :title="view.provider.title" :value="view.provider.value" @change="emit('provider-change', $event.target.value)"><option v-for="option in view.provider.options" :key="option.value" :value="option.value">{{ option.label }}</option></select>
|
||||
</label>
|
||||
<button v-if="view.panel" id="gev-voice-settings-button" class="gev-voice-settings-button" type="button" :aria-label="t('Voice Settings')" aria-controls="gev-voice-panel" :aria-expanded="String(view.voicePanel.settingsExpanded)" @click="emit('settings-button')"><span class="material-symbols-outlined gev-voice-settings-icon" aria-hidden="true">settings</span></button>
|
||||
<div class="gev-voice-heading">
|
||||
<div class="gev-voice-kicker">{{ t('AI AGENT') }}</div>
|
||||
<div id="gev-voice-status">{{ view.status.text }}</div>
|
||||
<span v-if="view.panel" id="gev-voice-cost-warning" class="gev-voice-cost-value gev-voice-cost-warning" :hidden="view.costWarning.hidden" :title="optional(view.costWarning.title)" :data-level="optional(view.costWarning.level)" role="status" aria-live="polite">{{ view.costWarning.text }}</span>
|
||||
<div v-else class="gev-voice-cost">
|
||||
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" :hidden="view.tier.hidden" :aria-pressed="String(view.tier.pressed)" :title="view.tier.title" @click="emit('tier')">{{ view.tier.text }}</button>
|
||||
<span id="gev-voice-cost-value" class="gev-voice-cost-value" :hidden="view.cost.hidden" :data-level="view.cost.level" :title="view.cost.title">{{ view.cost.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="gev-voice-button" type="button" :aria-label="view.button.label" :aria-pressed="optional(view.button.pressed)" aria-describedby="gev-voice-help" @click="emit('button')">
|
||||
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
|
||||
<span class="gev-mic-label">{{ view.mic.text }}</span>
|
||||
</button>
|
||||
<VoiceVisualizer />
|
||||
<div class="gev-voice-readout">
|
||||
<div id="gev-voice-detail" :title="optional(view.detail.title)">{{ view.detail.text }}</div>
|
||||
</div>
|
||||
<div id="gev-voice-help" class="gev-voice-help-tray" role="tooltip">
|
||||
<span class="gev-voice-help-kicker">{{ t('VOICE CONTROL') }}</span>
|
||||
<span class="gev-voice-help-detail">{{ view.help.text }}</span>
|
||||
</div>
|
||||
<section v-if="view.panel" id="gev-voice-panel" class="gev-voice-panel" :hidden="view.voicePanel.hidden" :data-view="optional(view.voicePanel.view)" :aria-label="t('Voice Settings')" @keydown="emit('panel-keydown', $event)">
|
||||
<div class="gev-voice-panel-header">
|
||||
<span class="gev-voice-panel-title">{{ t('VOICE') }}</span>
|
||||
<div class="gev-voice-panel-tabs" role="tablist" :aria-label="t('Voice Settings')">
|
||||
<button id="gev-voice-preview-tab" class="gev-voice-panel-tab" type="button" role="tab" :aria-label="t('Preview')" :title="t('Preview')" :aria-selected="String(view.voicePanel.selected === 'preview')" :tabindex="view.voicePanel.selected === 'preview' ? 0 : -1" aria-controls="gev-voice-preview-panel" @click="emit('tab', 0)" @keydown="emit('tab-keydown', 0, $event)"><span class="material-symbols-outlined gev-voice-panel-tab-icon" aria-hidden="true">visibility</span></button>
|
||||
<button id="gev-voice-settings-tab" class="gev-voice-panel-tab" type="button" role="tab" :aria-label="t('Settings')" :title="t('Settings')" :aria-selected="String(view.voicePanel.selected === 'settings')" :tabindex="view.voicePanel.selected === 'settings' ? 0 : -1" aria-controls="gev-voice-settings-panel" @click="emit('tab', 1)" @keydown="emit('tab-keydown', 1, $event)"><span class="material-symbols-outlined gev-voice-panel-tab-icon" aria-hidden="true">settings</span></button>
|
||||
</div>
|
||||
<button id="gev-voice-settings-close" type="button" :aria-label="t('Close voice panel')" @click="emit('settings-close')">×</button>
|
||||
</div>
|
||||
<div class="gev-voice-panel-body ui-scroll">
|
||||
<section id="gev-voice-preview-panel" role="tabpanel" aria-labelledby="gev-voice-preview-tab" :hidden="view.voicePanel.previewHidden">
|
||||
<p id="gev-voice-preview-empty" :hidden="view.previewEmpty.hidden">{{ view.previewEmpty.text }}</p>
|
||||
<VoiceCard :card="view.card" @dismiss="emit('card-dismiss')" />
|
||||
</section>
|
||||
<section id="gev-voice-settings-panel" role="tabpanel" aria-labelledby="gev-voice-settings-tab" :hidden="view.voicePanel.settingsHidden">
|
||||
<label class="gev-voice-preference gev-voice-preference-toggle"><input id="gev-voice-preview-enabled" type="checkbox" aria-describedby="gev-voice-preview-note" v-checked="view.preferences.previewChecked" @change="emit('preview-change', $event.target.checked)" /><span>{{ t('Show Voice Preview') }}</span></label>
|
||||
<p id="gev-voice-preview-note" class="gev-voice-settings-note">{{ view.preferences.previewNote }}</p>
|
||||
<div class="gev-voice-timeout-group">
|
||||
<label class="gev-voice-preference gev-voice-setting-title" for="gev-voice-timeout-select">{{ t('Voice inactivity timeout') }}</label>
|
||||
<div class="gev-voice-timeout-controls">
|
||||
<select id="gev-voice-timeout-select" class="gev-field" aria-describedby="gev-voice-timeout-note gev-voice-timeout-status" :value="view.preferences.timeoutChoice" @change="emit('timeout-select', $event.target.value)">
|
||||
<option value="3">3 {{ t('Minutes') }}</option><option value="5">5 {{ t('Minutes') }}</option><option value="10">10 {{ t('Minutes') }}</option><option value="15">15 {{ t('Minutes') }}</option>
|
||||
<option value="custom">{{ t('Custom') }}</option><option value="0">{{ t('None') }}</option>
|
||||
</select>
|
||||
<label class="gev-voice-timeout-unit" for="gev-voice-timeout-input">{{ t('Minutes') }}</label>
|
||||
<input id="gev-voice-timeout-input" type="number" min="1" max="60" step="1" aria-describedby="gev-voice-timeout-note" v-owned-value="{ text: view.preferences.timeoutMinutes, revision: view.preferences.revision }" :hidden="view.preferences.timeoutInputHidden" @change="emit('timeout-input', $event.target.value)" />
|
||||
</div>
|
||||
<p id="gev-voice-timeout-note" class="gev-voice-settings-note">{{ view.preferences.timeoutNote }}</p>
|
||||
<p id="gev-voice-timeout-status" class="gev-voice-settings-note" role="status" aria-live="polite">{{ view.preferences.timeoutStatus }}</p>
|
||||
</div>
|
||||
<div class="gev-voice-cost-settings">
|
||||
<div class="gev-voice-cost-setting-row">
|
||||
<span>{{ t('Voice model tier — applies next session') }}</span>
|
||||
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" :hidden="view.tier.hidden" :aria-pressed="String(view.tier.pressed)" :title="view.tier.title" @click="emit('tier')">{{ view.tier.text }}</button>
|
||||
</div>
|
||||
<div class="gev-voice-cost-setting-row">
|
||||
<span>{{ t('Estimated session cost') }}</span>
|
||||
<span id="gev-voice-cost-value" class="gev-voice-cost-value" :hidden="view.cost.hidden" :data-level="view.cost.level" :title="view.cost.title">{{ view.cost.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<VoiceCard v-else :card="view.card" @dismiss="emit('card-dismiss')" />
|
||||
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
|
||||
<div class="gev-voice-error-header">
|
||||
<span>{{ t('VOICE SYSTEM ERROR') }}</span>
|
||||
<button class="gev-voice-error-dismiss" type="button" @click="controls?.dismissError()">{{ t('DISMISS') }}</button>
|
||||
</div>
|
||||
<div id="gev-voice-error-detail">{{ view.error.text }}</div>
|
||||
<div class="gev-voice-error-hint">{{ t('Check microphone permission and network access, then try again.') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { VOICE_VISUALIZER_BARS } from '../../../voice/control.js';
|
||||
import { useVoiceStore } from '../../stores/voice.js';
|
||||
|
||||
// The dock waveform. Its bars change every animation frame while voice is
|
||||
// live, so they render here alone, not with the rest of the control.
|
||||
const { bars } = storeToRefs(useVoiceStore());
|
||||
const barStyle = (index) => ({
|
||||
'--bar': index,
|
||||
...(bars.value?.[index] && {
|
||||
'--audio-level': bars.value[index].level,
|
||||
'--audio-opacity': bars.value[index].opacity,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gev-voice-visualizer" aria-hidden="true">
|
||||
<span v-for="index in VOICE_VISUALIZER_BARS" :key="index" :style="barStyle(index - 1)"></span>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user