@jskit-ai/assistant-voice 0.1.1 → 0.1.3
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.
- package/examples/conversation/App.vue +16 -3
- package/examples/conversation/package.json +8 -8
- package/package.json +2 -1
- package/src/client/VoiceConversation.vue +26 -60
- package/src/client/VoiceConversationControls.vue +85 -0
- package/src/client/VoiceConversationHost.vue +5 -2
- package/src/client/index.js +1 -0
- package/examples/conversation/dist/assets/error-DOwCrGXH.js +0 -1
- package/examples/conversation/dist/assets/index-Bq1DwQwF.js +0 -90
- package/examples/conversation/dist/assets/index-Bw7_l3Cu.css +0 -1
- package/examples/conversation/dist/assets/placement-CUwf_TAl.js +0 -1
- package/examples/conversation/dist/assets/placementTopology-CUwf_TAl.js +0 -1
- package/examples/conversation/dist/index.html +0 -3
- package/examples/conversation/node_modules/.vite/deps/@jskit-ai_kernel_client_moduleBootstrap.js +0 -824
- package/examples/conversation/node_modules/.vite/deps/@jskit-ai_kernel_client_moduleBootstrap.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/@mdi_js.js +0 -7450
- package/examples/conversation/node_modules/.vite/deps/@mdi_js.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/@tanstack_vue-query.js +0 -3965
- package/examples/conversation/node_modules/.vite/deps/@tanstack_vue-query.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/VTooltip-Nwckus73.js +0 -2311
- package/examples/conversation/node_modules/.vite/deps/VTooltip-Nwckus73.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/_metadata.json +0 -110
- package/examples/conversation/node_modules/.vite/deps/defineComponent-Bx_4rnHZ.js +0 -710
- package/examples/conversation/node_modules/.vite/deps/defineComponent-Bx_4rnHZ.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/dist-CUIjXXPV.js +0 -3878
- package/examples/conversation/node_modules/.vite/deps/dist-CUIjXXPV.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/hotkey-parsing-B5h5TiMG.js +0 -1214
- package/examples/conversation/node_modules/.vite/deps/hotkey-parsing-B5h5TiMG.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/icons-DPtxVIU0.js +0 -98
- package/examples/conversation/node_modules/.vite/deps/icons-DPtxVIU0.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/json-rest-schema.js +0 -2071
- package/examples/conversation/node_modules/.vite/deps/json-rest-schema.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/package.json +0 -3
- package/examples/conversation/node_modules/.vite/deps/pinia.js +0 -1453
- package/examples/conversation/node_modules/.vite/deps/pinia.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/socket__io-client.js +0 -3899
- package/examples/conversation/node_modules/.vite/deps/socket__io-client.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/theme-BSn2V8xv.js +0 -1301
- package/examples/conversation/node_modules/.vite/deps/theme-BSn2V8xv.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/vue-router.js +0 -2989
- package/examples/conversation/node_modules/.vite/deps/vue-router.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/vue.js +0 -2
- package/examples/conversation/node_modules/.vite/deps/vue.runtime.esm-bundler-DWdvffrK.js +0 -8890
- package/examples/conversation/node_modules/.vite/deps/vue.runtime.esm-bundler-DWdvffrK.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/vuetify.js +0 -413
- package/examples/conversation/node_modules/.vite/deps/vuetify.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/vuetify_components.js +0 -25370
- package/examples/conversation/node_modules/.vite/deps/vuetify_components.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/vuetify_directives.js +0 -132
- package/examples/conversation/node_modules/.vite/deps/vuetify_directives.js.map +0 -1
- package/examples/conversation/node_modules/.vite/deps/vuetify_iconsets_mdi-svg.js +0 -70
- package/examples/conversation/node_modules/.vite/deps/vuetify_iconsets_mdi-svg.js.map +0 -1
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { inject, nextTick, onScopeDispose, ref } from "vue";
|
|
3
3
|
import { createVoiceConversationController, projectConversationVoiceState, VoiceConversationHost } from "@jskit-ai/assistant-voice/client";
|
|
4
4
|
import { ShellErrorHost } from "@jskit-ai/shell-web/client";
|
|
5
|
+
import { useAssistantConversationFactory } from "@jskit-ai/assistant-runtime/client";
|
|
5
6
|
import Conversation from "./Conversation.vue";
|
|
6
7
|
|
|
7
8
|
const example = inject("example.application");
|
|
8
9
|
const closed = ref([]);
|
|
9
10
|
const views = new Map();
|
|
10
11
|
const error = ref("");
|
|
12
|
+
const avatarSize = ref("compact");
|
|
13
|
+
const acquireConversation = useAssistantConversationFactory();
|
|
11
14
|
const controller = createVoiceConversationController({ connectSpeech: binding => binding.socketUrl });
|
|
12
15
|
async function openText(id) {
|
|
13
16
|
closed.value = closed.value.filter(value => value !== id);
|
|
@@ -18,12 +21,22 @@ function voiceBinding(runtime, label) {
|
|
|
18
21
|
const identity = runtime.identity;
|
|
19
22
|
let retained;
|
|
20
23
|
return {
|
|
21
|
-
id: JSON.stringify([identity.actorKey, identity.conversationId]), label,
|
|
24
|
+
id: JSON.stringify([identity.actorKey, identity.conversationId]), conversationId: identity.conversationId, label,
|
|
25
|
+
get adapter() { return retained?.adapter.value; },
|
|
22
26
|
socketUrl: `/api/conversations/${encodeURIComponent(identity.conversationId)}/voice`,
|
|
23
27
|
get state() { return projectConversationVoiceState({ turns: runtime.turns.value, status: runtime.snapshot.value?.status }); },
|
|
24
28
|
get available() { return runtime.available.value; },
|
|
25
29
|
captureContext: () => ({ ...identity }),
|
|
26
|
-
retain() {
|
|
30
|
+
retain() {
|
|
31
|
+
retained = acquireConversation({ conversationId: identity.conversationId, endpoint: identity.endpoint,
|
|
32
|
+
surfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
|
|
33
|
+
actorKey: identity.actorKey, workspaceSlug: identity.workspaceSlug,
|
|
34
|
+
presentation: { assistantLabel: label, layout: "compact" } });
|
|
35
|
+
if (retained.runtime.value !== runtime) {
|
|
36
|
+
retained.release(); retained = null;
|
|
37
|
+
throw new Error("Voice must retain the original conversation runtime.");
|
|
38
|
+
}
|
|
39
|
+
},
|
|
27
40
|
release() { retained?.release(); retained = null; },
|
|
28
41
|
submitText(text, { messageId, context } = {}) {
|
|
29
42
|
if (context?.actorKey !== identity.actorKey || context?.conversationId !== identity.conversationId) {
|
|
@@ -78,7 +91,7 @@ onScopeDispose(() => { void controller.dispose(); });
|
|
|
78
91
|
</section>
|
|
79
92
|
</div>
|
|
80
93
|
</main>
|
|
81
|
-
<VoiceConversationHost :controller="controller" />
|
|
94
|
+
<VoiceConversationHost v-model:avatar-size="avatarSize" :controller="controller" />
|
|
82
95
|
<ShellErrorHost />
|
|
83
96
|
</v-app>
|
|
84
97
|
</template>
|
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@fastify/websocket": "^11.2.0",
|
|
12
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
13
|
-
"@jskit-ai/assistant-runtime": "0.1.
|
|
14
|
-
"@jskit-ai/assistant-voice": "0.1.
|
|
15
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
16
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
17
|
-
"@jskit-ai/kernel": "0.1.
|
|
18
|
-
"@jskit-ai/realtime": "0.1.
|
|
19
|
-
"@jskit-ai/shell-web": "0.1.
|
|
12
|
+
"@jskit-ai/assistant-core": "0.1.203",
|
|
13
|
+
"@jskit-ai/assistant-runtime": "0.1.196",
|
|
14
|
+
"@jskit-ai/assistant-voice": "0.1.3",
|
|
15
|
+
"@jskit-ai/connectors-catalog": "0.1.42",
|
|
16
|
+
"@jskit-ai/http-runtime": "0.1.223",
|
|
17
|
+
"@jskit-ai/kernel": "0.1.225",
|
|
18
|
+
"@jskit-ai/realtime": "0.1.222",
|
|
19
|
+
"@jskit-ai/shell-web": "0.1.229",
|
|
20
20
|
"@mdi/js": "^7.4.47",
|
|
21
21
|
"@tanstack/vue-query": "^5.90.5",
|
|
22
22
|
"fastify": "^5.8.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-voice",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Duplex voice conversations with application-owned targets, authorization and speech services.",
|
|
6
6
|
"scripts": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"examples"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
+
"@jskit-ai/assistant-core": "0.1.203",
|
|
21
22
|
"@mdi/js": "^7.4.47",
|
|
22
23
|
"ws": "^8.20.1"
|
|
23
24
|
},
|
|
@@ -1,28 +1,18 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { ref, watch } from "vue";
|
|
3
|
+
import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
|
|
4
4
|
import VoiceAvatar from "./VoiceAvatar.vue";
|
|
5
|
-
import
|
|
5
|
+
import VoiceConversationControls from "./VoiceConversationControls.vue";
|
|
6
6
|
|
|
7
|
-
const props = defineProps({
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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; }
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
session: { type: Object, required: true }, disabled: { type: Boolean, default: false },
|
|
9
|
+
adapter: { type: Object, default: null },
|
|
10
|
+
avatarSize: { type: String, default: "compact" }
|
|
25
11
|
});
|
|
12
|
+
defineEmits(["update:avatarSize"]);
|
|
13
|
+
const { targetLabel, voice, microphoneMuted, heldTranscript, pendingTranscript, sending,
|
|
14
|
+
avatarVisual, callAudioLevel, voiceWords, voiceAnswer } = props.session;
|
|
15
|
+
const toolsTarget = ref(null);
|
|
26
16
|
const voiceWordsTarget = ref(null);
|
|
27
17
|
const voiceAnswerTarget = ref(null);
|
|
28
18
|
const followWords = ref(true);
|
|
@@ -36,18 +26,23 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
|
|
|
36
26
|
</script>
|
|
37
27
|
|
|
38
28
|
<template>
|
|
39
|
-
<fieldset class="assistant-voice" :disabled="disabled">
|
|
40
|
-
|
|
29
|
+
<fieldset v-if="adapter" class="assistant-voice-conversation" :disabled="disabled">
|
|
30
|
+
<AssistantConversationElement :adapter="adapter" :avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)">
|
|
31
|
+
<template #avatar="size"><slot name="avatar" v-bind="avatarVisual" :size="size.size" :height="size.height"><VoiceAvatar v-bind="avatarVisual" /></slot></template>
|
|
32
|
+
<template #composer-tools><div ref="toolsTarget" class="assistant-voice-conversation__tools" /></template>
|
|
33
|
+
<template #composer-feedback>
|
|
34
|
+
<VoiceConversationControls :session="session" :disabled="disabled" compact :tools-target="toolsTarget">
|
|
35
|
+
<template #work-control><slot name="work-control" /></template>
|
|
36
|
+
</VoiceConversationControls>
|
|
37
|
+
</template>
|
|
38
|
+
</AssistantConversationElement>
|
|
39
|
+
</fieldset>
|
|
40
|
+
<fieldset v-else class="assistant-voice" :disabled="disabled">
|
|
41
41
|
<section class="assistant-voice__call" :aria-label="`${targetLabel} voice chat controls`">
|
|
42
42
|
<div class="assistant-voice__call-body">
|
|
43
43
|
<div class="assistant-voice__call-portrait" :style="{ '--call-audio-scale': 1 + callAudioLevel * .16 }" aria-hidden="true">
|
|
44
44
|
<slot name="avatar" v-bind="avatarVisual"><VoiceAvatar v-bind="avatarVisual" /></slot>
|
|
45
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
46
|
</div>
|
|
52
47
|
<div class="assistant-voice__captions" aria-label="Voice conversation text">
|
|
53
48
|
<section ref="voiceWordsTarget" class="assistant-voice__caption" aria-label="Recognized words" tabindex="0" @scroll="followWords = isAtBottom($event.currentTarget)">
|
|
@@ -59,36 +54,14 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
|
|
|
59
54
|
<p class="assistant-voice__caption-text text-body-medium">{{ voiceAnswer }}</p>
|
|
60
55
|
</section>
|
|
61
56
|
</div>
|
|
62
|
-
<
|
|
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>
|
|
57
|
+
<VoiceConversationControls :session="session" :disabled="disabled"><template #work-control><slot name="work-control" /></template></VoiceConversationControls>
|
|
87
58
|
</section>
|
|
88
59
|
</fieldset>
|
|
89
60
|
</template>
|
|
90
61
|
|
|
91
62
|
<style scoped>
|
|
63
|
+
.assistant-voice-conversation { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; min-width: 0; height: 100%; border: 0; padding: 0; margin: 0; }
|
|
64
|
+
.assistant-voice-conversation__tools { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
92
65
|
.assistant-voice { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
93
66
|
.assistant-voice__call { width: 100%; flex: 1; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) minmax(144px, 30%) auto; }
|
|
94
67
|
.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; }
|
|
@@ -101,17 +74,10 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
|
|
|
101
74
|
.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
75
|
.assistant-voice__caption > strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
103
76
|
.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
77
|
@media (max-height: 650px) {
|
|
111
78
|
.assistant-voice__call-body { padding-top: 0; }
|
|
112
79
|
.assistant-voice__captions { gap: 4px; }
|
|
113
80
|
.assistant-voice__caption { padding-block: 4px; }
|
|
114
|
-
.assistant-voice__talk-action { padding-top: 8px; }
|
|
115
81
|
}
|
|
116
82
|
@media (prefers-reduced-motion: reduce) { .assistant-voice__call-portrait::before { transition: none; transform: none; } }
|
|
117
83
|
</style>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, useId } from "vue";
|
|
3
|
+
import { mdiMicrophone, mdiMicrophoneOff, mdiStop, mdiVolumeHigh, mdiVolumeOff } from "@mdi/js";
|
|
4
|
+
import { useVoiceLauncher } from "./voiceLauncher.js";
|
|
5
|
+
|
|
6
|
+
const props = defineProps({ session: { type: Object, required: true }, disabled: { type: Boolean, default: false }, compact: { type: Boolean, default: false }, toolsTarget: { type: Object, default: null } });
|
|
7
|
+
const {
|
|
8
|
+
targetLabel, voice, error, microphoneMuted, live, starting, callMode,
|
|
9
|
+
callStatus, speechActive, heldTranscript,
|
|
10
|
+
capturing, pendingTranscript, pushHolding, sending,
|
|
11
|
+
stopSpeech, startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleHandsFree, readAloud, toggleReadAloud,
|
|
12
|
+
deliverTranscript, discardHeldRecording, editTranscript
|
|
13
|
+
} = props.session;
|
|
14
|
+
const handsFreeListening = computed(() => live.value && callMode.value === "hands-free" && !microphoneMuted.value);
|
|
15
|
+
const canTalk = computed(() => !props.disabled && !sending.value && !pendingTranscript.value &&
|
|
16
|
+
(!capturing.value || voice.listening.value || pushHolding.value || starting.value));
|
|
17
|
+
const talkLabel = computed(() => starting.value ? "Connecting…" : pushHolding.value ? "Release to send" : handsFreeListening.value ? "Pause" : "Talk");
|
|
18
|
+
const gestureHint = useId();
|
|
19
|
+
const gesture = useVoiceLauncher({
|
|
20
|
+
enabled: canTalk,
|
|
21
|
+
open: () => ({ startHeldRecording: startPushToTalk, finishHeldRecording: finishPushToTalk, discardHeldRecording: cancelPushToTalk }),
|
|
22
|
+
onTap: toggleHandsFree,
|
|
23
|
+
onError: cause => { error.value = cause.message; }
|
|
24
|
+
});
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<fieldset class="assistant-voice-controls" :class="{ 'assistant-voice-controls--compact': compact }" :disabled="disabled">
|
|
29
|
+
<p v-if="error || voice.error.value" class="assistant-voice__error" role="alert">{{ error || voice.error.value }}</p>
|
|
30
|
+
<p v-if="callStatus" class="assistant-voice-controls__status text-body-small" role="status">{{ callStatus }}</p>
|
|
31
|
+
<p v-if="compact && heldTranscript && !pendingTranscript" class="assistant-voice-controls__provisional" role="status" aria-label="Recognized words">Not sent · {{ heldTranscript }}</p>
|
|
32
|
+
<section v-if="pendingTranscript && (pendingTranscript.reviewBeforeSend || !sending)" class="assistant-voice__review" aria-label="Review voice message">
|
|
33
|
+
<v-textarea :model-value="pendingTranscript.text" label="Review your message" :disabled="sending" :density="compact ? 'compact' : 'default'" :rows="compact ? 1 : 2" :max-rows="compact ? 2 : 3" auto-grow hide-details @update:model-value="editTranscript" />
|
|
34
|
+
<div class="assistant-voice__review-actions">
|
|
35
|
+
<v-btn :disabled="sending" min-height="48" @click="discardHeldRecording">Discard</v-btn>
|
|
36
|
+
<v-btn :disabled="sending || !pendingTranscript.text.trim()" min-height="48" color="primary" @click="deliverTranscript">{{ sending ? 'Sending…' : 'Send' }}</v-btn>
|
|
37
|
+
</div>
|
|
38
|
+
</section>
|
|
39
|
+
<Teleport :to="toolsTarget" :disabled="!toolsTarget">
|
|
40
|
+
<fieldset class="assistant-voice-controls__tools" :class="{ 'assistant-voice-controls__tools--compact': compact }" :disabled="disabled" :aria-label="`${targetLabel} voice chat controls`">
|
|
41
|
+
<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" />
|
|
42
|
+
<slot name="work-control" />
|
|
43
|
+
<div class="assistant-voice__talk-action">
|
|
44
|
+
<v-btn
|
|
45
|
+
class="assistant-voice__talk" color="primary" :variant="handsFreeListening ? 'tonal' : 'flat'" :min-height="compact ? 48 : 64" rounded="xl"
|
|
46
|
+
:prepend-icon="voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff" :aria-pressed="handsFreeListening || pushHolding" :aria-describedby="gestureHint"
|
|
47
|
+
:disabled="!canTalk"
|
|
48
|
+
@pointerdown="gesture.pointerDown" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel"
|
|
49
|
+
@keydown.space.prevent="gesture.keyDown" @keyup.space.prevent="gesture.keyUp"
|
|
50
|
+
@keydown.enter.prevent="gesture.keyDown" @keyup.enter.prevent="gesture.keyUp" @blur="gesture.cancel" @contextmenu.prevent @click="gesture.click"
|
|
51
|
+
>
|
|
52
|
+
{{ talkLabel }}
|
|
53
|
+
</v-btn>
|
|
54
|
+
<v-btn
|
|
55
|
+
:icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" variant="tonal" :min-height="compact ? 48 : 64" :min-width="compact ? 48 : 64" rounded="xl"
|
|
56
|
+
:aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
|
|
57
|
+
:title="readAloud ? 'Spoken replies on' : 'Spoken replies off'" @click="toggleReadAloud"
|
|
58
|
+
/>
|
|
59
|
+
<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>
|
|
60
|
+
</div>
|
|
61
|
+
</fieldset>
|
|
62
|
+
</Teleport>
|
|
63
|
+
</fieldset>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<style scoped>
|
|
67
|
+
.assistant-voice-controls, .assistant-voice-controls__tools { border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
68
|
+
.assistant-voice-controls { flex: 0 0 auto; }
|
|
69
|
+
.assistant-voice-controls--compact { flex: 1 1 100%; width: 100%; }
|
|
70
|
+
.assistant-voice-controls__tools { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; }
|
|
71
|
+
.assistant-voice-controls__status { text-align: center; margin: 0; }
|
|
72
|
+
.assistant-voice-controls__provisional { margin: 0; max-height: 3rem; overflow-y: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
73
|
+
.assistant-voice__talk-action { width: min(288px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 6px 8px; padding: 16px; }
|
|
74
|
+
.assistant-voice__talk { touch-action: none; user-select: none; -webkit-touch-callout: none; }
|
|
75
|
+
.assistant-voice__talk-action p { grid-column: 1 / -1; margin: 0; text-align: center; white-space: nowrap; }
|
|
76
|
+
.assistant-voice__review { flex: 0 0 auto; max-height: 160px; overflow-y: auto; padding: 8px 16px; }
|
|
77
|
+
.assistant-voice__review-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
78
|
+
.assistant-voice__error { padding: 8px 16px; color: rgb(var(--v-theme-error)); }
|
|
79
|
+
.assistant-voice-controls__tools--compact { justify-content: flex-start; gap: 4px; }
|
|
80
|
+
.assistant-voice-controls__tools--compact .assistant-voice__talk-action { display: flex; padding: 0; width: auto; gap: 4px; }
|
|
81
|
+
.assistant-voice-controls__tools--compact .assistant-voice__talk-action p { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
82
|
+
.assistant-voice-controls--compact .assistant-voice__review { max-height: 128px; padding: 0; }
|
|
83
|
+
.assistant-voice-controls--compact .assistant-voice__error { margin: 0; padding: 0; }
|
|
84
|
+
@media (max-height: 650px) { .assistant-voice__talk-action { padding-top: 8px; } }
|
|
85
|
+
</style>
|
|
@@ -3,7 +3,8 @@ import { mdiCog, mdiHeadset, mdiStop } from "@mdi/js";
|
|
|
3
3
|
import ConversationDialog from "./ConversationDialog.vue";
|
|
4
4
|
import VoiceConversation from "./VoiceConversation.vue";
|
|
5
5
|
|
|
6
|
-
const props = defineProps({ controller: { type: Object, required: true }, activator: { type: Object, default: null } });
|
|
6
|
+
const props = defineProps({ controller: { type: Object, required: true }, activator: { type: Object, default: null }, avatarSize: { type: String, default: "compact" } });
|
|
7
|
+
defineEmits(["update:avatarSize"]);
|
|
7
8
|
const state = props.controller.state;
|
|
8
9
|
async function invoke(operation) {
|
|
9
10
|
try { await operation(); }
|
|
@@ -53,12 +54,14 @@ async function openText() {
|
|
|
53
54
|
</div>
|
|
54
55
|
</v-alert>
|
|
55
56
|
<div class="voice-host__body">
|
|
56
|
-
<
|
|
57
|
+
<slot name="conversation" :binding="state.binding" :session="state.session" :disabled="state.busy">
|
|
58
|
+
<VoiceConversation :key="state.binding.id" :session="state.session" :disabled="state.busy" :adapter="state.binding.adapter || null" :avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)">
|
|
57
59
|
<template v-if="$slots.avatar" #avatar="visual"><slot name="avatar" v-bind="visual" :binding="state.binding" /></template>
|
|
58
60
|
<template #work-control>
|
|
59
61
|
<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
62
|
</template>
|
|
61
63
|
</VoiceConversation>
|
|
64
|
+
</slot>
|
|
62
65
|
</div>
|
|
63
66
|
</ConversationDialog>
|
|
64
67
|
<template v-if="state.session && !state.visible">
|
package/src/client/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { createVoiceConversationController } from "./voiceController.js";
|
|
|
3
3
|
export { default as VoiceConversationHost } from "./VoiceConversationHost.vue";
|
|
4
4
|
export { default as ConversationDialog } from "./ConversationDialog.vue";
|
|
5
5
|
export { default as VoiceConversation } from "./VoiceConversation.vue";
|
|
6
|
+
export { default as VoiceConversationControls } from "./VoiceConversationControls.vue";
|
|
6
7
|
|
|
7
8
|
export { useVoiceLauncher } from "./voiceLauncher.js";
|
|
8
9
|
export { useVoiceAvatar } from "./voiceAvatar.js";
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
var e={};export{e as default};
|