@jskit-ai/assistant-voice 0.1.2 → 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
|
@@ -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";
|