@jskit-ai/assistant-voice 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +169 -2
  2. package/examples/conversation/App.vue +94 -0
  3. package/examples/conversation/Conversation.vue +28 -0
  4. package/examples/conversation/README.md +181 -0
  5. package/examples/conversation/config.js +15 -0
  6. package/examples/conversation/index.html +1 -0
  7. package/examples/conversation/integrations.example.json +16 -0
  8. package/examples/conversation/main.js +58 -0
  9. package/examples/conversation/package.json +32 -0
  10. package/examples/conversation/server.js +105 -0
  11. package/examples/conversation/src/error.js +1 -0
  12. package/examples/conversation/src/placement.js +1 -0
  13. package/examples/conversation/src/placementTopology.js +1 -0
  14. package/examples/conversation/vite.config.js +9 -0
  15. package/package.json +67 -4
  16. package/src/client/ConversationDialog.vue +67 -0
  17. package/src/client/VoiceAvatar.vue +18 -0
  18. package/src/client/VoiceConversation.vue +117 -0
  19. package/src/client/VoiceConversationHost.vue +78 -0
  20. package/src/client/audioCapture.js +198 -0
  21. package/src/client/conversationNarration.js +108 -0
  22. package/src/client/conversationVoiceState.js +18 -0
  23. package/src/client/index.js +9 -0
  24. package/src/client/voiceAvatar.js +234 -0
  25. package/src/client/voiceController.js +124 -0
  26. package/src/client/voiceConversation.js +557 -0
  27. package/src/client/voiceLauncher.js +63 -0
  28. package/src/client/voicePlayback.js +58 -0
  29. package/src/client/voiceTransport.js +890 -0
  30. package/src/server/index.js +5 -0
  31. package/src/server/voiceAccessToken.js +79 -0
  32. package/src/server/voiceDaemon.js +599 -0
  33. package/src/server/voiceProxy.js +199 -0
  34. package/src/server/voiceProxyConfig.js +70 -0
  35. package/src/server/voiceSocketClose.js +31 -0
  36. package/src/shared/protocol.js +439 -0
  37. package/src/shared/visemes.js +130 -0
@@ -0,0 +1,105 @@
1
+ import Fastify from "fastify";
2
+ import websocket from "@fastify/websocket";
3
+ import { randomUUID } from "node:crypto";
4
+ import { readFile } from "node:fs/promises";
5
+ import { resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { createAiConnectionResolver } from "@jskit-ai/connectors-catalog/server/ai";
8
+ import { createConversationRuntime, createFileConversationStorage } from "@jskit-ai/assistant-core/server/conversation";
9
+ import { registerFastifyConversations } from "@jskit-ai/assistant-runtime/server";
10
+ import { registerVoiceProxyRoute, resolveVoiceProxyConfig } from "@jskit-ai/assistant-voice/server";
11
+ import { appConfig, conversations } from "./config.js";
12
+
13
+ export async function createExampleServer({ env = process.env } = {}) {
14
+ const app = Fastify({ bodyLimit: 16384 });
15
+ await app.register(websocket);
16
+ const allowedOrigin = env.APP_ORIGIN || "http://127.0.0.1:5176";
17
+ const context = Object.freeze({ applicationId: "talking-assistant-example", subjectId: "local-user" });
18
+ const ids = new Set(conversations.map(value => value.id));
19
+ function requireLocalRequest(request) {
20
+ // Normal same-origin GETs may omit Origin. The shared socket readRequest()
21
+ // reconstructs headers without an HTTP method, so it also requires Origin.
22
+ // No hosted actor is made up for this local application.
23
+ if (!request || ((request.method !== "GET" || request.headers?.origin) && request.headers?.origin !== allowedOrigin)) {
24
+ throw Object.assign(new Error("Open this example on its configured origin."), { statusCode: 403 });
25
+ }
26
+ }
27
+ function authorizedContext(actor) {
28
+ if (actor === context) return true;
29
+ if (actor?.applicationId !== context.applicationId || actor?.subjectId !== context.subjectId) return false;
30
+ requireLocalRequest(actor.requestMeta?.request);
31
+ return true;
32
+ }
33
+ const engine = env.ASSISTANT_ENGINE || "api";
34
+ const integrationConfiguration = env.ASSISTANT_INTEGRATIONS
35
+ ? JSON.parse(await readFile(resolve(env.ASSISTANT_INTEGRATIONS), "utf8"))
36
+ : { schemaVersion: 1, registrations: {}, integrations: {} };
37
+ const connections = createAiConnectionResolver({
38
+ configuration: integrationConfiguration, authorize: actor => authorizedContext(actor) ? context : null
39
+ });
40
+ const runtime = createConversationRuntime({
41
+ engine, connections, defaultIntegrationId: "assistant",
42
+ host: { workdir: resolve(env.ASSISTANT_WORKDIR || ".") },
43
+ limits: { maxInputCharacters: 8000, maxOutputCharacters: 16000 },
44
+ storage: createFileConversationStorage({ directory: resolve(env.ASSISTANT_STORAGE_DIRECTORY || ".assistant/conversations") }),
45
+ authorize: ({ context: actor, conversationId }) => authorizedContext(actor) &&
46
+ (ids.has(conversationId) || (actor === context && conversationId === "headless"))
47
+ });
48
+ function configuration(label) {
49
+ return {
50
+ systemPrompt: `You are the assistant for ${label}. Answer briefly in plain language.`,
51
+ integrationId: env.ASSISTANT_INTEGRATION_ID || (env.ASSISTANT_INTEGRATIONS ? "assistant" : undefined),
52
+ model: env.ASSISTANT_NATIVE_MODEL || undefined,
53
+ effort: env.ASSISTANT_NATIVE_EFFORT || undefined
54
+ };
55
+ }
56
+ app.addHook("onClose", () => runtime.close());
57
+ registerVoiceProxyRoute(app, { route: "/api/conversations/:id/voice", proxyConfig: resolveVoiceProxyConfig({
58
+ endpoint: env.SPEECH_ENDPOINT, accessTokenFile: env.SPEECH_TOKEN_FILE
59
+ }), authorize(request) {
60
+ if (request.headers.origin !== allowedOrigin || !ids.has(request.params.id)) {
61
+ throw Object.assign(new Error("This conversation is not available for voice."), { statusCode: 403 });
62
+ }
63
+ } });
64
+ try {
65
+ for (const value of conversations) await runtime.open({ id: value.id, context, configuration: configuration(value.label) });
66
+ await registerFastifyConversations(app, {
67
+ runtime, config: appConfig, env,
68
+ authenticate(request) {
69
+ requireLocalRequest(request);
70
+ return context;
71
+ },
72
+ bootstrap: () => ({ example: { subjectId: context.subjectId, conversations,
73
+ provider: ({ claude: "Claude Code", codex: "Codex", opencode: "OpenCode" })[engine] ||
74
+ (env.ASSISTANT_INTEGRATIONS ? "Application AI connection" : "Configure ASSISTANT_INTEGRATIONS to send messages") } })
75
+ });
76
+ }
77
+ catch (error) { await app.close(); throw error; }
78
+
79
+ // A separately addressed server task uses the same runtime/store directly.
80
+ // It has no browser route and is never automatically resumed or retried.
81
+ async function runHeadless(text) {
82
+ const conversation = await runtime.open({ id: "headless", context, configuration: configuration("Headless task") });
83
+ await conversation.send({ messageId: randomUUID(), text });
84
+ const state = await conversation.wait();
85
+ if (state.error) throw new Error(state.error);
86
+ return state.conversationLog.at(-1)?.assistant?.text || "";
87
+ }
88
+ return { app, runtime, runHeadless };
89
+ }
90
+
91
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
92
+ const headless = process.argv[2] === "--headless";
93
+ const headlessText = process.argv.slice(3).join(" ").trim();
94
+ if (headless && !headlessText) throw new Error("Pass a task after --headless.");
95
+ const { app, runHeadless } = await createExampleServer();
96
+ await app.listen({ host: "127.0.0.1", port: 3042 });
97
+ for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
98
+ app.close().catch(error => { app.log.error(error); process.exitCode = 1; });
99
+ });
100
+ console.log("Voice example API ready on 127.0.0.1:3042");
101
+ if (headless) {
102
+ runHeadless(headlessText).then(answer => console.log(`Headless task: ${answer}`))
103
+ .catch(error => console.error(`Headless task failed: ${error.message}`));
104
+ }
105
+ }
@@ -0,0 +1 @@
1
+ export default {};
@@ -0,0 +1 @@
1
+ export default [];
@@ -0,0 +1 @@
1
+ export default [];
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vite";
2
+ import vue from "@vitejs/plugin-vue";
3
+ import { createJskitClientBootstrapPlugin } from "@jskit-ai/kernel/client/vite";
4
+ const apiTarget = "http://127.0.0.1:3042";
5
+ export default defineConfig({ plugins: [vue(), createJskitClientBootstrapPlugin({ proxyTarget: apiTarget })],
6
+ server: { proxy: {
7
+ "/api": { target: apiTarget, ws: true }
8
+ } }
9
+ });
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-voice",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "type": "module",
5
+ "description": "Duplex voice conversations with application-owned targets, authorization and speech services.",
6
+ "scripts": {
7
+ "test": "node --test --test-concurrency=1"
8
+ },
9
+ "exports": {
10
+ "./client": "./src/client/index.js",
11
+ "./server": "./src/server/index.js",
12
+ "./shared/protocol": "./src/shared/protocol.js",
13
+ "./shared/visemes": "./src/shared/visemes.js"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "examples"
19
+ ],
20
+ "dependencies": {
21
+ "@mdi/js": "^7.4.47",
22
+ "ws": "^8.20.1"
23
+ },
24
+ "jskit": {
25
+ "kind": "runtime",
26
+ "capabilities": {
27
+ "provides": [],
28
+ "requires": []
29
+ },
30
+ "runtime": {
31
+ "client": {
32
+ "providers": []
33
+ },
34
+ "server": {
35
+ "providers": []
36
+ }
37
+ },
38
+ "metadata": {
39
+ "apiSummary": {
40
+ "surfaces": [
41
+ {
42
+ "subpath": "./client",
43
+ "summary": "One application voice controller and Vue modal, with push-to-talk, hands-free capture, playback, review and avatar presentation."
44
+ },
45
+ {
46
+ "subpath": "./server",
47
+ "summary": "Authorized WebSocket proxy, bounded speech daemon, access tokens and service configuration."
48
+ },
49
+ {
50
+ "subpath": "./shared/protocol",
51
+ "summary": "Voice control validation, PCM frame limits and streamed speech text segmentation."
52
+ },
53
+ {
54
+ "subpath": "./shared/visemes",
55
+ "summary": "Shared mouth poses and pronunciation timing for voice avatars."
56
+ }
57
+ ],
58
+ "capabilities": []
59
+ }
60
+ }
61
+ },
62
+ "peerDependencies": {
63
+ "vue": "^3.5.13",
64
+ "vuetify": "^4.0.0"
65
+ },
66
+ "devDependencies": {
67
+ "@fastify/websocket": "^11.3.2"
68
+ }
69
+ }
@@ -0,0 +1,67 @@
1
+ <script setup>
2
+ import { mdiClose, mdiMessageTextOutline, mdiMicrophone, mdiMinus } from "@mdi/js";
3
+ import { useDisplay } from "vuetify";
4
+
5
+ const { xs } = useDisplay();
6
+
7
+ const props = defineProps({
8
+ modelValue: Boolean,
9
+ activator: { type: Object, default: null },
10
+ title: { type: String, default: "Assistant" },
11
+ mode: { type: String, default: "talk" },
12
+ showModes: Boolean,
13
+ minimizable: Boolean,
14
+ closeLabel: { type: String, default: "Close conversation" }
15
+ });
16
+ const emit = defineEmits(["update:modelValue", "update:mode", "minimize"]);
17
+ let dismissedFromActivator = false;
18
+ function outsideClick(event) {
19
+ // The scrim receives a pointer aimed at the covered launcher.
20
+ const bounds = props.minimizable && props.activator?.getBoundingClientRect();
21
+ dismissedFromActivator = Boolean(bounds && event.clientX >= bounds.left && event.clientX <= bounds.right &&
22
+ event.clientY >= bounds.top && event.clientY <= bounds.bottom);
23
+ }
24
+ function updateOpen(value) {
25
+ if (!value && dismissedFromActivator) emit("minimize");
26
+ else emit("update:modelValue", value);
27
+ dismissedFromActivator = false;
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <v-dialog
33
+ class="conversation-dialog" :activator="activator" :open-on-click="false"
34
+ :model-value="modelValue" :transition="false" :fullscreen="xs" :width="xs ? '100%' : 620" :max-width="xs ? '100%' : 'calc(100% - 32px)'"
35
+ :height="xs ? '100%' : 860" :max-height="xs ? '100%' : 'calc(100% - 32px)'"
36
+ :content-props="{ style: { margin: xs ? 0 : '16px' } }" :aria-label="`${title} conversation`" :aria-hidden="!modelValue"
37
+ @click:outside="outsideClick" @update:model-value="updateOpen"
38
+ >
39
+ <v-card class="conversation-dialog__surface" :rounded="xs ? 0 : 'lg'">
40
+ <v-card-title class="conversation-dialog__title text-title-large">
41
+ <span :title="title">{{ title }}</span>
42
+ <slot name="header-actions" />
43
+ <v-btn v-if="minimizable" :icon="mdiMinus" variant="text" aria-label="Minimize conversation" title="Minimize conversation" @click="$emit('minimize')" />
44
+ <v-btn :icon="mdiClose" variant="text" :aria-label="closeLabel" :title="closeLabel" @click="$emit('update:modelValue', false)" />
45
+ </v-card-title>
46
+ <v-tabs v-if="showModes" :model-value="mode" class="conversation-dialog__tabs" grow aria-label="Conversation mode" @update:model-value="$emit('update:mode', $event)">
47
+ <v-tab value="talk" :prepend-icon="mdiMicrophone">Talk</v-tab>
48
+ <v-tab value="text" :prepend-icon="mdiMessageTextOutline">Text</v-tab>
49
+ </v-tabs>
50
+ <div class="conversation-dialog__body"><slot /></div>
51
+ </v-card>
52
+ </v-dialog>
53
+ </template>
54
+
55
+ <style scoped>
56
+ /* VOverlay animates its scrim independently of the dialog transition. */
57
+ .conversation-dialog :deep(.v-overlay__scrim) { transition: none; opacity: var(--v-overlay-opacity); }
58
+ .conversation-dialog:not(.v-overlay--active) :deep(.v-overlay__scrim) { display: none; }
59
+ .conversation-dialog__surface { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
60
+ .conversation-dialog__title { display: flex; align-items: center; flex: 0 0 64px; height: 64px; gap: 8px; padding: 8px 16px; }
61
+ .conversation-dialog__title > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
62
+ .conversation-dialog__tabs { flex: 0 0 48px; height: 48px; }
63
+ .conversation-dialog__body { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
64
+ @media (max-width: 599px) {
65
+ .conversation-dialog__surface { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
66
+ }
67
+ </style>
@@ -0,0 +1,18 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import { useVoiceAvatar } from "./voiceAvatar.js";
4
+ const props = defineProps({ state: { type: String, default: "idle" }, mouthPose: { type: String, default: "closed" }, mouthLevel: { type: Number, default: 0 } });
5
+ const { blinking, visibleMouthPose, reducedMotion } = useVoiceAvatar(props);
6
+ const mouthHeight = computed(() => reducedMotion.value || visibleMouthPose.value === "closed" ? 3 : Math.max(4, Math.min(18, props.mouthLevel * 28)));
7
+ </script>
8
+ <template>
9
+ <svg viewBox="0 0 128 128" aria-label="Assistant avatar" role="img" class="voice-avatar">
10
+ <circle cx="64" cy="64" r="59" fill="currentColor" opacity=".1" />
11
+ <rect x="25" y="31" width="78" height="66" rx="24" fill="none" stroke="currentColor" stroke-width="3" />
12
+ <path d="M64 31V21" stroke="currentColor" stroke-width="3" />
13
+ <circle cx="64" cy="18" r="4" fill="currentColor" />
14
+ <ellipse v-for="x in [47,81]" :key="x" :cx="x" cy="55" rx="5" :ry="blinking ? 1 : 7" fill="currentColor" />
15
+ <ellipse cx="64" cy="77" :rx="visibleMouthPose === 'round' ? 6 : 15" :ry="mouthHeight" fill="currentColor" />
16
+ </svg>
17
+ </template>
18
+ <style scoped>.voice-avatar { color: rgb(var(--v-theme-primary)); width: 100%; height: 100%; }</style>
@@ -0,0 +1,117 @@
1
+ <script setup>
2
+ import { computed, ref, useId, watch } from "vue";
3
+ import { mdiMicrophone, mdiMicrophoneOff, mdiStop, mdiVolumeHigh, mdiVolumeOff } from "@mdi/js";
4
+ import VoiceAvatar from "./VoiceAvatar.vue";
5
+ import { useVoiceLauncher } from "./voiceLauncher.js";
6
+
7
+ const props = defineProps({ session: { type: Object, required: true }, disabled: { type: Boolean, default: false } });
8
+ const {
9
+ targetLabel, voice, error, microphoneMuted, live, starting, callMode,
10
+ callStatus, callAudioLevel, avatarVisual, speechActive, heldTranscript,
11
+ capturing, pendingTranscript, voiceWords, voiceAnswer, pushHolding, sending,
12
+ stopSpeech, startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleHandsFree, readAloud, toggleReadAloud,
13
+ deliverTranscript, discardHeldRecording, editTranscript
14
+ } = props.session;
15
+ const handsFreeListening = computed(() => live.value && callMode.value === "hands-free" && !microphoneMuted.value);
16
+ const canTalk = computed(() => !props.disabled && !sending.value && !pendingTranscript.value &&
17
+ (!capturing.value || voice.listening.value || pushHolding.value || starting.value));
18
+ const talkLabel = computed(() => starting.value ? "Connecting…" : pushHolding.value ? "Release to send" : handsFreeListening.value ? "Pause" : "Talk");
19
+ const gestureHint = useId();
20
+ const gesture = useVoiceLauncher({
21
+ enabled: canTalk,
22
+ open: () => ({ startHeldRecording: startPushToTalk, finishHeldRecording: finishPushToTalk, discardHeldRecording: cancelPushToTalk }),
23
+ onTap: toggleHandsFree,
24
+ onError: cause => { error.value = cause.message; }
25
+ });
26
+ const voiceWordsTarget = ref(null);
27
+ const voiceAnswerTarget = ref(null);
28
+ const followWords = ref(true);
29
+ const followAnswer = ref(true);
30
+ function isAtBottom(element) { return element.scrollHeight - element.scrollTop - element.clientHeight < 24; }
31
+ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWords], [voiceAnswer, voiceAnswerTarget, followAnswer]]) {
32
+ watch(text, () => {
33
+ if (target.value && following.value) target.value.scrollTop = target.value.scrollHeight;
34
+ }, { flush: "post" });
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <fieldset class="assistant-voice" :disabled="disabled">
40
+ <p v-if="error || voice.error.value" class="assistant-voice__error" role="alert">{{ error || voice.error.value }}</p>
41
+ <section class="assistant-voice__call" :aria-label="`${targetLabel} voice chat controls`">
42
+ <div class="assistant-voice__call-body">
43
+ <div class="assistant-voice__call-portrait" :style="{ '--call-audio-scale': 1 + callAudioLevel * .16 }" aria-hidden="true">
44
+ <slot name="avatar" v-bind="avatarVisual"><VoiceAvatar v-bind="avatarVisual" /></slot>
45
+ </div>
46
+ <div class="assistant-voice__call-status-row">
47
+ <p class="text-title-medium" role="status">{{ callStatus }}</p>
48
+ <v-btn v-if="speechActive" :icon="mdiStop" variant="text" min-width="48" min-height="48" :aria-label="`Stop ${targetLabel} speaking`" title="Stop speaking" @click="stopSpeech" />
49
+ <slot name="work-control" />
50
+ </div>
51
+ </div>
52
+ <div class="assistant-voice__captions" aria-label="Voice conversation text">
53
+ <section ref="voiceWordsTarget" class="assistant-voice__caption" aria-label="Recognized words" tabindex="0" @scroll="followWords = isAtBottom($event.currentTarget)">
54
+ <strong class="text-label-medium">You<span v-if="heldTranscript && voice.listening.value && !microphoneMuted"> · Hearing…</span><span v-else-if="pendingTranscript && !sending"> · Not sent</span></strong>
55
+ <p class="assistant-voice__caption-text text-body-medium">{{ voiceWords }}</p>
56
+ </section>
57
+ <section ref="voiceAnswerTarget" class="assistant-voice__caption" :aria-label="`${targetLabel} latest answer`" tabindex="0" @scroll="followAnswer = isAtBottom($event.currentTarget)">
58
+ <strong class="text-label-medium">{{ targetLabel }}</strong>
59
+ <p class="assistant-voice__caption-text text-body-medium">{{ voiceAnswer }}</p>
60
+ </section>
61
+ </div>
62
+ <section v-if="pendingTranscript && (pendingTranscript.reviewBeforeSend || !sending)" class="assistant-voice__review" aria-label="Review voice message">
63
+ <v-textarea :model-value="pendingTranscript.text" label="Review your message" :disabled="sending" rows="2" max-rows="3" auto-grow hide-details @update:model-value="editTranscript" />
64
+ <div class="assistant-voice__review-actions">
65
+ <v-btn :disabled="sending" min-height="48" @click="discardHeldRecording">Discard</v-btn>
66
+ <v-btn :disabled="sending || !pendingTranscript.text.trim()" min-height="48" color="primary" @click="deliverTranscript">{{ sending ? 'Sending…' : 'Send' }}</v-btn>
67
+ </div>
68
+ </section>
69
+ <div class="assistant-voice__talk-action">
70
+ <v-btn
71
+ class="assistant-voice__talk" color="primary" :variant="handsFreeListening ? 'tonal' : 'flat'" min-height="64" rounded="xl"
72
+ :prepend-icon="voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff" :aria-pressed="handsFreeListening || pushHolding" :aria-describedby="gestureHint"
73
+ :disabled="!canTalk"
74
+ @pointerdown="gesture.pointerDown" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel"
75
+ @keydown.space.prevent="gesture.keyDown" @keyup.space.prevent="gesture.keyUp"
76
+ @keydown.enter.prevent="gesture.keyDown" @keyup.enter.prevent="gesture.keyUp" @blur="gesture.cancel" @contextmenu.prevent @click="gesture.click"
77
+ >
78
+ {{ talkLabel }}
79
+ </v-btn>
80
+ <v-btn
81
+ :icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" variant="tonal" min-height="64" min-width="64" rounded="xl"
82
+ :aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
83
+ :title="readAloud ? 'Spoken replies on' : 'Spoken replies off'" @click="toggleReadAloud"
84
+ />
85
+ <p :id="gestureHint" class="text-body-small text-medium-emphasis">{{ handsFreeListening ? 'Tap to pause · hold to speak' : 'Tap for hands-free · hold to speak' }}</p>
86
+ </div>
87
+ </section>
88
+ </fieldset>
89
+ </template>
90
+
91
+ <style scoped>
92
+ .assistant-voice { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; border: 0; padding: 0; margin: 0; min-width: 0; }
93
+ .assistant-voice__call { width: 100%; flex: 1; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) minmax(144px, 30%) auto; }
94
+ .assistant-voice__call-body { min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px 16px 0; text-align: center; }
95
+ .assistant-voice__call-portrait { position: relative; height: min(240px, calc(100% - 64px)); aspect-ratio: 1; max-width: 100%; margin: 8px auto; isolation: isolate; }
96
+ .assistant-voice__call-portrait::before { content: ''; position: absolute; inset: -12px; border-radius: 50%; background: rgba(var(--v-theme-primary), .12); transform: scale(var(--call-audio-scale)); transition: transform 80ms linear; z-index: -1; }
97
+ .assistant-voice__call-portrait :deep(svg) { width: 100%; height: 100%; }
98
+ .assistant-voice__call-status-row { display: flex; flex: 0 0 48px; justify-content: center; align-items: center; gap: 8px; height: 48px; }
99
+ .assistant-voice__call-status-row p { margin: 0; max-height: 48px; overflow: hidden; }
100
+ .assistant-voice__captions { min-height: 0; display: grid; grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 0 16px; }
101
+ .assistant-voice__caption { min-height: 0; padding: 8px 12px; border-radius: 12px; background: rgba(var(--v-theme-on-surface), .04); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; touch-action: pan-y; }
102
+ .assistant-voice__caption > strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
103
+ .assistant-voice__caption-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
104
+ .assistant-voice__talk-action { justify-self: center; width: min(288px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 6px 8px; padding: 16px; }
105
+ .assistant-voice__talk { touch-action: none; user-select: none; -webkit-touch-callout: none; }
106
+ .assistant-voice__talk-action p { grid-column: 1 / -1; margin: 0; text-align: center; white-space: nowrap; }
107
+ .assistant-voice__review { flex: 0 0 auto; max-height: 160px; overflow-y: auto; padding: 8px 16px; }
108
+ .assistant-voice__review-actions { display: flex; justify-content: flex-end; gap: 8px; }
109
+ .assistant-voice__error { padding: 8px 16px; color: rgb(var(--v-theme-error)); }
110
+ @media (max-height: 650px) {
111
+ .assistant-voice__call-body { padding-top: 0; }
112
+ .assistant-voice__captions { gap: 4px; }
113
+ .assistant-voice__caption { padding-block: 4px; }
114
+ .assistant-voice__talk-action { padding-top: 8px; }
115
+ }
116
+ @media (prefers-reduced-motion: reduce) { .assistant-voice__call-portrait::before { transition: none; transform: none; } }
117
+ </style>
@@ -0,0 +1,78 @@
1
+ <script setup>
2
+ import { mdiCog, mdiHeadset, mdiStop } from "@mdi/js";
3
+ import ConversationDialog from "./ConversationDialog.vue";
4
+ import VoiceConversation from "./VoiceConversation.vue";
5
+
6
+ const props = defineProps({ controller: { type: Object, required: true }, activator: { type: Object, default: null } });
7
+ const state = props.controller.state;
8
+ async function invoke(operation) {
9
+ try { await operation(); }
10
+ catch (error) { state.error = error.message; }
11
+ }
12
+ async function openText() {
13
+ const opening = state.binding.openText();
14
+ props.controller.minimize();
15
+ try { await opening; }
16
+ catch (error) { props.controller.reveal(); throw error; }
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <ConversationDialog
22
+ v-if="state.session" :model-value="state.visible" :activator="activator" :title="state.binding.label || 'Assistant'" mode="talk" :show-modes="Boolean(state.binding.openText)"
23
+ minimizable close-label="Close voice chat" @minimize="controller.minimize()" @update:model-value="invoke(() => controller.end({ discard: true }))"
24
+ @update:mode="value => value === 'text' && invoke(openText)"
25
+ >
26
+ <template #header-actions>
27
+ <v-menu :close-on-content-click="false" location="bottom end" @update:model-value="open => open && !$slots.settings && invoke(() => state.session.voice.connect())">
28
+ <template #activator="{ props: settingsButton }">
29
+ <v-btn v-bind="settingsButton" :icon="mdiCog" variant="text" aria-label="Voice settings" title="Voice settings" />
30
+ </template>
31
+ <v-card class="voice-host__settings" role="region" aria-label="Voice settings">
32
+ <v-card-text>
33
+ <slot name="settings" :voice="state.session.voice">
34
+ <v-select
35
+ v-model="state.session.voice.selectedVoice.value"
36
+ :items="state.session.voice.availableVoices.value" item-title="label" item-value="id"
37
+ :disabled="state.session.voice.availableVoices.value.length < 2"
38
+ label="Speaking voice" density="comfortable"
39
+ hint="Applies to the next spoken reply" persistent-hint
40
+ />
41
+ </slot>
42
+ </v-card-text>
43
+ </v-card>
44
+ </v-menu>
45
+ </template>
46
+ <v-alert v-if="state.error" type="error" density="compact" role="alert">{{ state.error }}</v-alert>
47
+ <v-alert v-if="state.nextTarget" type="info" class="voice-host__switch">
48
+ Finish or discard your recording for {{ state.binding.label || 'this conversation' }} before switching to {{ state.nextTarget.label || 'another conversation' }}.
49
+ <div class="voice-host__switch-actions">
50
+ <v-btn variant="text" @click="controller.cancelSwitch()">Stay here</v-btn>
51
+ <v-btn :disabled="state.busy || state.session.sending.value" variant="text" @click="invoke(() => controller.switchTarget({ discard: true }))">Discard and switch</v-btn>
52
+ <v-btn v-if="!state.session.hasUnsentSpeech.value" :disabled="state.busy" variant="text" @click="invoke(() => controller.switchTarget())">Switch</v-btn>
53
+ </div>
54
+ </v-alert>
55
+ <div class="voice-host__body">
56
+ <VoiceConversation :key="state.binding.id" :session="state.session" :disabled="state.busy">
57
+ <template v-if="$slots.avatar" #avatar="visual"><slot name="avatar" v-bind="visual" :binding="state.binding" /></template>
58
+ <template #work-control>
59
+ <v-btn v-if="state.binding.cancelWork && state.binding.state.status === 'working'" :disabled="state.busy" :icon="mdiStop" variant="text" aria-label="Stop agent work" title="Stop agent work" @click="invoke(state.binding.cancelWork)" />
60
+ </template>
61
+ </VoiceConversation>
62
+ </div>
63
+ </ConversationDialog>
64
+ <template v-if="state.session && !state.visible">
65
+ <slot v-if="$slots.reopen" name="reopen" :controller="controller" />
66
+ <v-btn v-else class="voice-host__reopen" color="primary" :prepend-icon="mdiHeadset" min-height="56" @click="controller.reveal()">
67
+ {{ state.binding.label || 'Assistant' }} · Voice chat
68
+ </v-btn>
69
+ </template>
70
+ </template>
71
+
72
+ <style scoped>
73
+ .voice-host__body { display: flex; flex: 1; min-height: 0; overflow: hidden; }
74
+ .voice-host__switch-actions { display: flex; flex-wrap: wrap; gap: 4px; }
75
+ .voice-host__switch { flex: 0 0 auto; }
76
+ .voice-host__settings { width: 320px; max-width: calc(100vw - 32px); }
77
+ .voice-host__reopen { position: fixed; right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); max-width: calc(100vw - 24px); z-index: 1900; }
78
+ </style>