@jskit-ai/assistant-voice 0.1.3 → 0.1.5
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/README.md +38 -2
- package/examples/conversation/App.vue +2 -1
- package/examples/conversation/package.json +8 -8
- package/package.json +2 -2
- package/src/client/ConversationDialog.vue +23 -12
- package/src/client/VoiceConversation.vue +41 -15
- package/src/client/VoiceConversationControls.vue +102 -40
- package/src/client/VoiceConversationHost.vue +32 -31
- package/src/client/VoiceConversationSettings.vue +52 -0
- package/src/client/index.js +1 -0
- package/src/client/voiceConversation.js +223 -65
- package/src/client/voiceTransport.js +58 -10
package/README.md
CHANGED
|
@@ -83,9 +83,45 @@ The host's `avatar` slot receives state, mouthLevel, mouthPose, avatar and bindi
|
|
|
83
83
|
Opening a target does not start capture. Tap Talk for hands-free, or hold it for
|
|
84
84
|
push-to-talk and release to send; connection setup is automatic.
|
|
85
85
|
Pause finishes the current utterance, flushing the audio tail before muting.
|
|
86
|
-
Completed words follow the captured review policy
|
|
86
|
+
Completed words follow the captured review policy. Pending delivery does not disable
|
|
87
|
+
the microphone: tap can resume the existing continuous capture or start another
|
|
88
|
+
one while preserving the prior pending transcript. A busy Pause or hold release
|
|
89
|
+
mutes newer capture and finishes it through the original owner after that prior
|
|
90
|
+
admission settles. Resuming first keeps the capture open. No pending words are
|
|
91
|
+
replaced or appended. The icon reflects actual unmuted capture, not readiness.
|
|
87
92
|
Automatic sends keep the call layout fixed. A failed send exposes the retained
|
|
88
|
-
transcript for review and retry with the same message identity.
|
|
93
|
+
transcript for review and retry with the same message identity. An application
|
|
94
|
+
can call `beginTranscriptEdit(messageId)` for its exact unsent preview; this
|
|
95
|
+
retains the existing pending transcript and capture destination before cancelling
|
|
96
|
+
only capture. `editTranscript(text, messageId)` updates that pending identity and
|
|
97
|
+
`deliverTranscript()` keeps the original send/retry owner. Hands-free resumes
|
|
98
|
+
through the existing pending-clear watcher after send/discard, not during editing.
|
|
99
|
+
An adapter's `previewMessage.actions` may project `editing`, `update(id, text)`,
|
|
100
|
+
`send(id)`, `canSend` and `sending` alongside its original ×/edit actions. The
|
|
101
|
+
standard transcript uses the same review textarea inside that preview bubble,
|
|
102
|
+
including an empty editor, and reuses its existing action row for pending Send.
|
|
103
|
+
A matching canonical user with `receipt: false` is still unadmitted: it does not
|
|
104
|
+
clear pending words, forbid Edit or acknowledge a speech invitation. Confirmed
|
|
105
|
+
readback (an absent flag or `receipt: true`) retains the original identity-based
|
|
106
|
+
acknowledgement. For a known failed local delivery, a host can cancel that exact
|
|
107
|
+
entry through the original delivery owner only upon explicit Edit, then author
|
|
108
|
+
fresh edited text and intent on Send. Plain Retry retains its original payload;
|
|
109
|
+
uncertain, accepted and sending entries remain fenced.
|
|
110
|
+
When continuous recognition has newer words while an earlier pending send fails,
|
|
111
|
+
both identities remain in their original pending/recording owners. Hosts can
|
|
112
|
+
project these two existing snapshots through `previewMessages`, deduplicated by
|
|
113
|
+
ID, while retaining the singular `previewMessage` contract. `canTakeTranscript(id)`
|
|
114
|
+
checks either exact existing identity with the original admission guards. The
|
|
115
|
+
newer capture is discardable, but cannot replace the occupied pending editor.
|
|
116
|
+
Editing the earlier transcript pauses the newer recording through the original
|
|
117
|
+
mute owner, retaining its words, ID, focus and endpoint. Transient
|
|
118
|
+
`editPausedCaptureId` on the pending transcript owns only this pause; an explicit
|
|
119
|
+
microphone choice supersedes it. Original pending resolution releases only the
|
|
120
|
+
matching Edit-owned pause, and the existing endpoint watcher handles newer words.
|
|
121
|
+
No additional transcript queue, playback owner or capture process is created.
|
|
122
|
+
The app must retain current-target and uncertain-admission guards. Controls use
|
|
123
|
+
`reviewInTranscript` only while that preview is projected, suppressing a duplicate
|
|
124
|
+
inline review; standalone voice retains its original review fallback. Captions,
|
|
89
125
|
review, explicit microphone/sound controls and bounded reconnection share one
|
|
90
126
|
session. Opening the same ID reveals it. Switching targets releases the previous
|
|
91
127
|
audio first; unfinished words require completion or explicit discard. A pending
|
|
@@ -10,6 +10,7 @@ const closed = ref([]);
|
|
|
10
10
|
const views = new Map();
|
|
11
11
|
const error = ref("");
|
|
12
12
|
const avatarSize = ref("compact");
|
|
13
|
+
const voicePresentation = ref("dialog");
|
|
13
14
|
const acquireConversation = useAssistantConversationFactory();
|
|
14
15
|
const controller = createVoiceConversationController({ connectSpeech: binding => binding.socketUrl });
|
|
15
16
|
async function openText(id) {
|
|
@@ -91,7 +92,7 @@ onScopeDispose(() => { void controller.dispose(); });
|
|
|
91
92
|
</section>
|
|
92
93
|
</div>
|
|
93
94
|
</main>
|
|
94
|
-
<VoiceConversationHost v-model:avatar-size="avatarSize" :controller="controller" />
|
|
95
|
+
<VoiceConversationHost v-model:avatar-size="avatarSize" :controller="controller" :presentation="voicePresentation" />
|
|
95
96
|
<ShellErrorHost />
|
|
96
97
|
</v-app>
|
|
97
98
|
</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.205",
|
|
13
|
+
"@jskit-ai/assistant-runtime": "0.1.198",
|
|
14
|
+
"@jskit-ai/assistant-voice": "0.1.5",
|
|
15
|
+
"@jskit-ai/connectors-catalog": "0.1.44",
|
|
16
|
+
"@jskit-ai/http-runtime": "0.1.225",
|
|
17
|
+
"@jskit-ai/kernel": "0.1.227",
|
|
18
|
+
"@jskit-ai/realtime": "0.1.224",
|
|
19
|
+
"@jskit-ai/shell-web": "0.1.231",
|
|
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.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Duplex voice conversations with application-owned targets, authorization and speech services.",
|
|
6
6
|
"scripts": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"examples"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
21
|
+
"@jskit-ai/assistant-core": "0.1.205",
|
|
22
22
|
"@mdi/js": "^7.4.47",
|
|
23
23
|
"ws": "^8.20.1"
|
|
24
24
|
},
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { mdiClose, mdiMessageTextOutline, mdiMicrophone, mdiMinus } from "@mdi/js";
|
|
3
3
|
import { useDisplay } from "vuetify";
|
|
4
|
+
import { VCard, VDialog } from "vuetify/components";
|
|
5
|
+
import { computed } from "vue";
|
|
4
6
|
|
|
5
7
|
const { xs } = useDisplay();
|
|
6
8
|
|
|
@@ -11,7 +13,16 @@ const props = defineProps({
|
|
|
11
13
|
mode: { type: String, default: "talk" },
|
|
12
14
|
showModes: Boolean,
|
|
13
15
|
minimizable: Boolean,
|
|
14
|
-
closeLabel: { type: String, default: "Close conversation" }
|
|
16
|
+
closeLabel: { type: String, default: "Close conversation" },
|
|
17
|
+
presentation: { type: String, default: "dialog", validator: value => ["dialog", "inline"].includes(value) }
|
|
18
|
+
});
|
|
19
|
+
const dialogProps = computed(() => props.presentation === "inline" ? {} : {
|
|
20
|
+
activator: props.activator, openOnClick: false, modelValue: props.modelValue,
|
|
21
|
+
transition: false, fullscreen: xs.value, width: xs.value ? "100%" : 620,
|
|
22
|
+
maxWidth: xs.value ? "100%" : "calc(100% - 32px)", height: xs.value ? "100%" : 860,
|
|
23
|
+
maxHeight: xs.value ? "100%" : "calc(100% - 32px)",
|
|
24
|
+
contentProps: { style: { margin: xs.value ? 0 : "16px" } },
|
|
25
|
+
"aria-hidden": !props.modelValue
|
|
15
26
|
});
|
|
16
27
|
const emit = defineEmits(["update:modelValue", "update:mode", "minimize"]);
|
|
17
28
|
let dismissedFromActivator = false;
|
|
@@ -29,18 +40,16 @@ function updateOpen(value) {
|
|
|
29
40
|
</script>
|
|
30
41
|
|
|
31
42
|
<template>
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
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"
|
|
43
|
+
<component
|
|
44
|
+
:is="presentation === 'inline' ? 'section' : VDialog" v-bind="dialogProps"
|
|
45
|
+
class="conversation-dialog" :class="{ 'conversation-dialog--inline': presentation === 'inline' }" :aria-label="`${title} conversation`"
|
|
37
46
|
@click:outside="outsideClick" @update:model-value="updateOpen"
|
|
38
47
|
>
|
|
39
|
-
<
|
|
40
|
-
<v-card-title class="conversation-dialog__title text-title-large">
|
|
48
|
+
<component :is="presentation === 'inline' ? 'div' : VCard" class="conversation-dialog__surface" :rounded="presentation === 'inline' ? undefined : xs ? 0 : 'lg'">
|
|
49
|
+
<v-card-title v-if="presentation !== 'inline'" class="conversation-dialog__title text-title-large">
|
|
41
50
|
<span :title="title">{{ title }}</span>
|
|
42
51
|
<slot name="header-actions" />
|
|
43
|
-
<v-btn v-if="minimizable" :icon="mdiMinus" variant="text" aria-label="Minimize conversation" title="Minimize conversation" @click="$emit('minimize')" />
|
|
52
|
+
<v-btn v-if="minimizable" :icon="mdiMinus" variant="text" aria-label="Minimize conversation" title="Minimize conversation" @click="$emit('minimize', $event)" />
|
|
44
53
|
<v-btn :icon="mdiClose" variant="text" :aria-label="closeLabel" :title="closeLabel" @click="$emit('update:modelValue', false)" />
|
|
45
54
|
</v-card-title>
|
|
46
55
|
<v-tabs v-if="showModes" :model-value="mode" class="conversation-dialog__tabs" grow aria-label="Conversation mode" @update:model-value="$emit('update:mode', $event)">
|
|
@@ -48,8 +57,8 @@ function updateOpen(value) {
|
|
|
48
57
|
<v-tab value="text" :prepend-icon="mdiMessageTextOutline">Text</v-tab>
|
|
49
58
|
</v-tabs>
|
|
50
59
|
<div class="conversation-dialog__body"><slot /></div>
|
|
51
|
-
</
|
|
52
|
-
</
|
|
60
|
+
</component>
|
|
61
|
+
</component>
|
|
53
62
|
</template>
|
|
54
63
|
|
|
55
64
|
<style scoped>
|
|
@@ -57,11 +66,13 @@ function updateOpen(value) {
|
|
|
57
66
|
.conversation-dialog :deep(.v-overlay__scrim) { transition: none; opacity: var(--v-overlay-opacity); }
|
|
58
67
|
.conversation-dialog:not(.v-overlay--active) :deep(.v-overlay__scrim) { display: none; }
|
|
59
68
|
.conversation-dialog__surface { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
|
|
69
|
+
.conversation-dialog--inline { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
|
70
|
+
.conversation-dialog--inline .conversation-dialog__surface { height: auto; }
|
|
60
71
|
.conversation-dialog__title { display: flex; align-items: center; flex: 0 0 64px; height: 64px; gap: 8px; padding: 8px 16px; }
|
|
61
72
|
.conversation-dialog__title > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
62
73
|
.conversation-dialog__tabs { flex: 0 0 48px; height: 48px; }
|
|
63
74
|
.conversation-dialog__body { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
|
|
64
75
|
@media (max-width: 599px) {
|
|
65
|
-
.conversation-dialog__surface { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
|
|
76
|
+
.conversation-dialog:not(.conversation-dialog--inline) .conversation-dialog__surface { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
|
|
66
77
|
}
|
|
67
78
|
</style>
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, watch } from "vue";
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
3
|
import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
|
|
4
4
|
import VoiceAvatar from "./VoiceAvatar.vue";
|
|
5
5
|
import VoiceConversationControls from "./VoiceConversationControls.vue";
|
|
6
|
+
import { mdiMicrophone, mdiVolumeHigh } from "@mdi/js";
|
|
6
7
|
|
|
7
8
|
const props = defineProps({
|
|
8
9
|
session: { type: Object, required: true }, disabled: { type: Boolean, default: false },
|
|
9
10
|
adapter: { type: Object, default: null },
|
|
11
|
+
showAvatar: { type: Boolean, default: true },
|
|
10
12
|
avatarSize: { type: String, default: "compact" }
|
|
11
13
|
});
|
|
12
14
|
defineEmits(["update:avatarSize"]);
|
|
13
15
|
const { targetLabel, voice, microphoneMuted, heldTranscript, pendingTranscript, sending,
|
|
14
16
|
avatarVisual, callAudioLevel, voiceWords, voiceAnswer } = props.session;
|
|
17
|
+
const presentationAdapter = computed(() => props.adapter && ({
|
|
18
|
+
...props.adapter,
|
|
19
|
+
composer: { ...props.adapter.composer, canSend: props.adapter.composer?.canSend && !props.session.composerBlocked.value }
|
|
20
|
+
}));
|
|
15
21
|
const toolsTarget = ref(null);
|
|
16
22
|
const voiceWordsTarget = ref(null);
|
|
17
23
|
const voiceAnswerTarget = ref(null);
|
|
@@ -27,19 +33,37 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
|
|
|
27
33
|
|
|
28
34
|
<template>
|
|
29
35
|
<fieldset v-if="adapter" class="assistant-voice-conversation" :disabled="disabled">
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
<AssistantConversationElement :adapter="presentationAdapter" :avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)">
|
|
37
|
+
<template v-if="showAvatar" #avatar="size">
|
|
38
|
+
<slot name="avatar" v-bind="avatarVisual" :size="size.size" :height="size.height">
|
|
39
|
+
<VoiceAvatar v-bind="avatarVisual" />
|
|
40
|
+
</slot>
|
|
41
|
+
</template>
|
|
42
|
+
<template v-if="showAvatar" #avatar-tools><div ref="toolsTarget" class="assistant-voice-conversation__tools" /></template>
|
|
43
|
+
<template #avatar-control="scope">
|
|
44
|
+
<slot name="avatar-control" v-bind="scope">
|
|
45
|
+
<span v-if="scope.size === 'hidden' && voice.listening.value && !microphoneMuted" role="img" aria-label="Listening" title="Listening">
|
|
46
|
+
<v-icon :icon="mdiMicrophone" size="20" aria-hidden="true" />
|
|
47
|
+
</span>
|
|
48
|
+
<span v-if="scope.size === 'hidden' && voice.speaking.value" role="img" aria-label="Speaking" title="Speaking">
|
|
49
|
+
<v-icon :icon="mdiVolumeHigh" size="20" aria-hidden="true" />
|
|
50
|
+
</span>
|
|
51
|
+
</slot>
|
|
52
|
+
</template>
|
|
53
|
+
<template #composer-feedback>
|
|
54
|
+
<VoiceConversationControls :session="session" :disabled="disabled" compact :icon-only="showAvatar" :tools-target="toolsTarget" :review-in-transcript="Boolean(presentationAdapter?.conversation?.previewMessage)">
|
|
55
|
+
<template #work-control><slot name="work-control" /></template>
|
|
56
|
+
<template #settings-control><slot name="settings-control" /></template>
|
|
57
|
+
</VoiceConversationControls>
|
|
58
|
+
</template>
|
|
59
|
+
</AssistantConversationElement>
|
|
39
60
|
</fieldset>
|
|
40
61
|
<fieldset v-else class="assistant-voice" :disabled="disabled">
|
|
41
|
-
<section
|
|
42
|
-
|
|
62
|
+
<section
|
|
63
|
+
class="assistant-voice__call" :class="{ 'assistant-voice__call--without-avatar': !showAvatar }"
|
|
64
|
+
:aria-label="`${targetLabel} voice chat controls`"
|
|
65
|
+
>
|
|
66
|
+
<div v-if="showAvatar" class="assistant-voice__call-body">
|
|
43
67
|
<div class="assistant-voice__call-portrait" :style="{ '--call-audio-scale': 1 + callAudioLevel * .16 }" aria-hidden="true">
|
|
44
68
|
<slot name="avatar" v-bind="avatarVisual"><VoiceAvatar v-bind="avatarVisual" /></slot>
|
|
45
69
|
</div>
|
|
@@ -54,7 +78,10 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
|
|
|
54
78
|
<p class="assistant-voice__caption-text text-body-medium">{{ voiceAnswer }}</p>
|
|
55
79
|
</section>
|
|
56
80
|
</div>
|
|
57
|
-
<VoiceConversationControls :session="session" :disabled="disabled"
|
|
81
|
+
<VoiceConversationControls :session="session" :disabled="disabled">
|
|
82
|
+
<template #work-control><slot name="work-control" /></template>
|
|
83
|
+
<template #settings-control><slot name="settings-control" /></template>
|
|
84
|
+
</VoiceConversationControls>
|
|
58
85
|
</section>
|
|
59
86
|
</fieldset>
|
|
60
87
|
</template>
|
|
@@ -64,12 +91,11 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
|
|
|
64
91
|
.assistant-voice-conversation__tools { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
65
92
|
.assistant-voice { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
66
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--without-avatar { grid-template-rows: minmax(144px, 1fr) auto; }
|
|
67
95
|
.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; }
|
|
68
96
|
.assistant-voice__call-portrait { position: relative; height: min(240px, calc(100% - 64px)); aspect-ratio: 1; max-width: 100%; margin: 8px auto; isolation: isolate; }
|
|
69
97
|
.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; }
|
|
70
98
|
.assistant-voice__call-portrait :deep(svg) { width: 100%; height: 100%; }
|
|
71
|
-
.assistant-voice__call-status-row { display: flex; flex: 0 0 48px; justify-content: center; align-items: center; gap: 8px; height: 48px; }
|
|
72
|
-
.assistant-voice__call-status-row p { margin: 0; max-height: 48px; overflow: hidden; }
|
|
73
99
|
.assistant-voice__captions { min-height: 0; display: grid; grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 0 16px; }
|
|
74
100
|
.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; }
|
|
75
101
|
.assistant-voice__caption > strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -3,17 +3,31 @@ import { computed, useId } from "vue";
|
|
|
3
3
|
import { mdiMicrophone, mdiMicrophoneOff, mdiStop, mdiVolumeHigh, mdiVolumeOff } from "@mdi/js";
|
|
4
4
|
import { useVoiceLauncher } from "./voiceLauncher.js";
|
|
5
5
|
|
|
6
|
-
const props = defineProps({
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
session: { type: Object, required: true },
|
|
8
|
+
disabled: { type: Boolean, default: false },
|
|
9
|
+
compact: { type: Boolean, default: false },
|
|
10
|
+
iconOnly: { type: Boolean, default: false },
|
|
11
|
+
reviewInTranscript: { type: Boolean, default: false },
|
|
12
|
+
toolsTarget: { type: Object, default: null },
|
|
13
|
+
feedbackTarget: { type: Object, default: null }
|
|
14
|
+
});
|
|
7
15
|
const {
|
|
8
16
|
targetLabel, voice, error, microphoneMuted, live, starting, callMode,
|
|
9
17
|
callStatus, speechActive, heldTranscript,
|
|
10
18
|
capturing, pendingTranscript, pushHolding, sending,
|
|
11
|
-
stopSpeech, startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleHandsFree, readAloud, toggleReadAloud,
|
|
19
|
+
stopSpeech, startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleHandsFree, readAloud, toggleReadAloud, enableSound,
|
|
12
20
|
deliverTranscript, discardHeldRecording, editTranscript
|
|
13
21
|
} = props.session;
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
(!
|
|
22
|
+
const controlStatus = computed(() => {
|
|
23
|
+
if (props.iconOnly && voice.listening.value && !starting.value && !error.value && !voice.error.value) return "";
|
|
24
|
+
if (!props.compact || !props.session.hasUnsentSpeech.value) return callStatus.value;
|
|
25
|
+
if (pendingTranscript.value) return props.reviewInTranscript ? "" : "Send or discard speech to send text.";
|
|
26
|
+
if (live.value && callMode.value === "hands-free" && voice.listening.value && !microphoneMuted.value) return "Listening…";
|
|
27
|
+
return voice.listening.value ? "Recording · finish speech to send text." : "Finish speech to send text.";
|
|
28
|
+
});
|
|
29
|
+
const handsFreeListening = computed(() => live.value && callMode.value === "hands-free" && voice.listening.value && !microphoneMuted.value);
|
|
30
|
+
const canTalk = computed(() => !props.disabled && (!capturing.value || voice.listening.value || pushHolding.value || starting.value));
|
|
17
31
|
const talkLabel = computed(() => starting.value ? "Connecting…" : pushHolding.value ? "Release to send" : handsFreeListening.value ? "Pause" : "Talk");
|
|
18
32
|
const gestureHint = useId();
|
|
19
33
|
const gesture = useVoiceLauncher({
|
|
@@ -26,49 +40,83 @@ const gesture = useVoiceLauncher({
|
|
|
26
40
|
|
|
27
41
|
<template>
|
|
28
42
|
<fieldset class="assistant-voice-controls" :class="{ 'assistant-voice-controls--compact': compact }" :disabled="disabled">
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
</
|
|
38
|
-
|
|
43
|
+
<Teleport :to="feedbackTarget" :disabled="!feedbackTarget">
|
|
44
|
+
<fieldset class="assistant-voice-controls__feedback" :class="{ 'assistant-voice-controls__feedback--compact': compact }" :disabled="disabled">
|
|
45
|
+
<p
|
|
46
|
+
v-if="error || voice.error.value" class="assistant-voice__error"
|
|
47
|
+
:class="{ 'text-body-small': compact }" role="alert"
|
|
48
|
+
>
|
|
49
|
+
{{ error || voice.error.value }}
|
|
50
|
+
</p>
|
|
51
|
+
<p v-if="controlStatus" class="assistant-voice-controls__status text-body-small" role="status" :title="callStatus">{{ controlStatus }}</p>
|
|
52
|
+
<p v-if="compact && !reviewInTranscript && heldTranscript && !pendingTranscript" class="assistant-voice-controls__provisional" role="status" aria-label="Recognized words">Not sent · {{ heldTranscript }}</p>
|
|
53
|
+
<p v-if="readAloud && voice.playbackBlocked?.value" class="assistant-voice-controls__status" role="status">Sound is blocked. Enable sound to hear future replies.</p>
|
|
54
|
+
<v-btn v-if="readAloud && voice.playbackBlocked?.value" min-height="48" @click="enableSound">Enable sound</v-btn>
|
|
55
|
+
<section v-if="!reviewInTranscript && pendingTranscript && (pendingTranscript.reviewBeforeSend || !sending)" class="assistant-voice__review" aria-label="Review voice message">
|
|
56
|
+
<v-textarea
|
|
57
|
+
class="assistant-voice__review-input" :model-value="pendingTranscript.text" label="Review your message" :disabled="sending"
|
|
58
|
+
:density="compact ? 'compact' : 'default'" :rows="compact ? 1 : 2" :max-rows="compact ? 2 : 3"
|
|
59
|
+
auto-grow hide-details @update:model-value="editTranscript"
|
|
60
|
+
/>
|
|
61
|
+
<div class="assistant-voice__review-actions">
|
|
62
|
+
<v-btn :disabled="sending" min-height="48" @click="discardHeldRecording">Discard</v-btn>
|
|
63
|
+
<v-btn :disabled="sending || !pendingTranscript.text.trim()" min-height="48" color="primary" @click="deliverTranscript">{{ sending ? 'Sending…' : 'Send' }}</v-btn>
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
</fieldset>
|
|
67
|
+
</Teleport>
|
|
39
68
|
<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`">
|
|
69
|
+
<fieldset class="assistant-voice-controls__tools" :class="{ 'assistant-voice-controls__tools--compact': compact, 'assistant-voice-controls__tools--icons': iconOnly }" :disabled="disabled" :aria-label="`${targetLabel} voice chat controls`">
|
|
70
|
+
<div v-if="compact || iconOnly || speechActive" class="assistant-voice-controls__stop">
|
|
41
71
|
<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
|
-
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
</div>
|
|
73
|
+
<slot name="work-control" />
|
|
74
|
+
<div class="assistant-voice__talk-action">
|
|
75
|
+
<v-btn
|
|
76
|
+
class="assistant-voice__talk" color="primary" :variant="iconOnly ? 'text' : handsFreeListening ? 'tonal' : 'flat'" :min-height="iconOnly ? 44 : compact ? 48 : 64" rounded="xl"
|
|
77
|
+
:icon="iconOnly ? (voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff) : undefined"
|
|
78
|
+
:prepend-icon="iconOnly ? undefined : (voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff)"
|
|
79
|
+
:aria-label="iconOnly ? talkLabel : undefined" title="click or long press to talk"
|
|
80
|
+
:aria-pressed="handsFreeListening || pushHolding" :aria-describedby="gestureHint"
|
|
81
|
+
:disabled="!canTalk" :slim="compact || iconOnly"
|
|
82
|
+
:width="iconOnly ? 40 : compact ? 148 : undefined" :min-width="iconOnly ? 40 : compact ? 148 : undefined"
|
|
83
|
+
@pointerdown="gesture.pointerDown" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel"
|
|
84
|
+
@keydown.space.prevent="gesture.keyDown" @keyup.space.prevent="gesture.keyUp"
|
|
85
|
+
@keydown.enter.prevent="gesture.keyDown" @keyup.enter.prevent="gesture.keyUp" @blur="gesture.cancel" @contextmenu.prevent @click="gesture.click"
|
|
86
|
+
>
|
|
87
|
+
<span v-if="iconOnly" class="assistant-voice-controls__icon-disc"><v-icon size="20" :icon="voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff" /></span>
|
|
88
|
+
<template v-else>{{ talkLabel }}</template>
|
|
89
|
+
</v-btn>
|
|
90
|
+
<v-btn
|
|
91
|
+
:icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" :variant="iconOnly ? 'text' : 'tonal'" :color="iconOnly ? 'primary' : undefined" :min-height="iconOnly ? 44 : compact ? 48 : 64" :width="iconOnly ? 40 : undefined" :min-width="iconOnly ? 40 : compact ? 48 : 64" rounded="xl"
|
|
92
|
+
:aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
|
|
93
|
+
:title="readAloud ? 'Spoken replies on' : 'Spoken replies off'" @click="toggleReadAloud"
|
|
94
|
+
>
|
|
95
|
+
<template v-if="iconOnly" #default>
|
|
96
|
+
<span class="assistant-voice-controls__icon-disc"><v-icon size="20" :icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" /></span>
|
|
97
|
+
</template>
|
|
98
|
+
</v-btn>
|
|
99
|
+
<slot name="settings-control" />
|
|
100
|
+
<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>
|
|
101
|
+
</div>
|
|
61
102
|
</fieldset>
|
|
62
103
|
</Teleport>
|
|
63
104
|
</fieldset>
|
|
64
105
|
</template>
|
|
65
106
|
|
|
66
107
|
<style scoped>
|
|
67
|
-
.assistant-voice-controls, .assistant-voice-controls__tools { border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
108
|
+
.assistant-voice-controls, .assistant-voice-controls__feedback, .assistant-voice-controls__tools { border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
68
109
|
.assistant-voice-controls { flex: 0 0 auto; }
|
|
69
110
|
.assistant-voice-controls--compact { flex: 1 1 100%; width: 100%; }
|
|
70
111
|
.assistant-voice-controls__tools { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; }
|
|
71
112
|
.assistant-voice-controls__status { text-align: center; margin: 0; }
|
|
113
|
+
.assistant-voice-controls__feedback--compact > .assistant-voice-controls__status { min-height: 1lh; }
|
|
114
|
+
.assistant-voice-controls__stop { display: contents; }
|
|
115
|
+
.assistant-voice-controls__tools--compact .assistant-voice-controls__stop {
|
|
116
|
+
display: block;
|
|
117
|
+
flex: 0 0 48px;
|
|
118
|
+
height: 48px;
|
|
119
|
+
}
|
|
72
120
|
.assistant-voice-controls__provisional { margin: 0; max-height: 3rem; overflow-y: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
73
121
|
.assistant-voice__talk-action { width: min(288px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 6px 8px; padding: 16px; }
|
|
74
122
|
.assistant-voice__talk { touch-action: none; user-select: none; -webkit-touch-callout: none; }
|
|
@@ -76,10 +124,24 @@ const gesture = useVoiceLauncher({
|
|
|
76
124
|
.assistant-voice__review { flex: 0 0 auto; max-height: 160px; overflow-y: auto; padding: 8px 16px; }
|
|
77
125
|
.assistant-voice__review-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
78
126
|
.assistant-voice__error { padding: 8px 16px; color: rgb(var(--v-theme-error)); }
|
|
79
|
-
.assistant-voice-controls__tools--compact { justify-content: flex-start; gap:
|
|
80
|
-
.assistant-voice-controls__tools--compact .assistant-voice__talk-action { display: flex; padding: 0; width: auto; gap:
|
|
127
|
+
.assistant-voice-controls__tools--compact { justify-content: flex-start; gap: 2px; }
|
|
128
|
+
.assistant-voice-controls__tools--compact .assistant-voice__talk-action { display: flex; padding: 0; width: auto; gap: 0; }
|
|
81
129
|
.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-
|
|
83
|
-
.assistant-voice-
|
|
130
|
+
.assistant-voice-controls__tools--icons { position: relative; width: max-content; flex-wrap: nowrap; justify-content: flex-end; gap: 0; }
|
|
131
|
+
.assistant-voice-controls__tools--icons .assistant-voice-controls__stop { position: absolute; right: calc(100% + 2px); top: 0; width: 48px; height: 48px; pointer-events: none; }
|
|
132
|
+
.assistant-voice-controls__tools--icons .assistant-voice-controls__stop > .v-btn { pointer-events: auto; }
|
|
133
|
+
.assistant-voice-controls__icon-disc { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: rgba(var(--v-theme-primary), .12); }
|
|
134
|
+
.assistant-voice-controls__tools--icons .assistant-voice__talk-action { display: flex; padding: 0; width: auto; gap: 0; }
|
|
135
|
+
.assistant-voice-controls__tools--icons .assistant-voice__talk-action p { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
136
|
+
.assistant-voice-controls__feedback--compact .assistant-voice__review {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: flex-end;
|
|
139
|
+
gap: 4px;
|
|
140
|
+
max-height: 128px;
|
|
141
|
+
padding: 0;
|
|
142
|
+
}
|
|
143
|
+
.assistant-voice-controls__feedback--compact .assistant-voice__review-input { flex: 1 1 auto; min-width: 0; }
|
|
144
|
+
.assistant-voice-controls__feedback--compact .assistant-voice__review-actions { flex: 0 0 auto; }
|
|
145
|
+
.assistant-voice-controls__feedback--compact .assistant-voice__error { margin: 0; padding: 0; }
|
|
84
146
|
@media (max-height: 650px) { .assistant-voice__talk-action { padding-top: 8px; } }
|
|
85
147
|
</style>
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import {
|
|
2
|
+
import { mdiHeadset, mdiStop } from "@mdi/js";
|
|
3
3
|
import ConversationDialog from "./ConversationDialog.vue";
|
|
4
4
|
import VoiceConversation from "./VoiceConversation.vue";
|
|
5
|
+
import VoiceConversationSettings from "./VoiceConversationSettings.vue";
|
|
5
6
|
|
|
6
|
-
const props = defineProps({
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
controller: { type: Object, required: true }, activator: { type: Object, default: null },
|
|
9
|
+
avatarSize: { type: String, default: "hidden" },
|
|
10
|
+
presentation: { type: String, default: "dialog", validator: value => ["dialog", "inline"].includes(value) }
|
|
11
|
+
});
|
|
7
12
|
defineEmits(["update:avatarSize"]);
|
|
8
13
|
const state = props.controller.state;
|
|
9
14
|
async function invoke(operation) {
|
|
@@ -20,29 +25,16 @@ async function openText() {
|
|
|
20
25
|
|
|
21
26
|
<template>
|
|
22
27
|
<ConversationDialog
|
|
23
|
-
v-if="state.session" :model-value="state.visible" :activator="activator" :
|
|
24
|
-
|
|
28
|
+
v-if="state.session" :model-value="state.visible" :activator="activator" :presentation="presentation"
|
|
29
|
+
:title="state.binding.label || 'Assistant'" mode="talk"
|
|
30
|
+
:show-modes="Boolean(state.binding.openText && !state.binding.adapter)"
|
|
31
|
+
:minimizable="presentation === 'dialog'" close-label="Close voice chat" @minimize="controller.minimize()" @update:model-value="invoke(() => controller.end({ discard: true }))"
|
|
25
32
|
@update:mode="value => value === 'text' && invoke(openText)"
|
|
26
33
|
>
|
|
27
|
-
<template #header-actions>
|
|
28
|
-
<
|
|
29
|
-
<template #
|
|
30
|
-
|
|
31
|
-
</template>
|
|
32
|
-
<v-card class="voice-host__settings" role="region" aria-label="Voice settings">
|
|
33
|
-
<v-card-text>
|
|
34
|
-
<slot name="settings" :voice="state.session.voice">
|
|
35
|
-
<v-select
|
|
36
|
-
v-model="state.session.voice.selectedVoice.value"
|
|
37
|
-
:items="state.session.voice.availableVoices.value" item-title="label" item-value="id"
|
|
38
|
-
:disabled="state.session.voice.availableVoices.value.length < 2"
|
|
39
|
-
label="Speaking voice" density="comfortable"
|
|
40
|
-
hint="Applies to the next spoken reply" persistent-hint
|
|
41
|
-
/>
|
|
42
|
-
</slot>
|
|
43
|
-
</v-card-text>
|
|
44
|
-
</v-card>
|
|
45
|
-
</v-menu>
|
|
34
|
+
<template v-if="!state.binding.adapter" #header-actions>
|
|
35
|
+
<VoiceConversationSettings :voice="state.session.voice" @error="state.error = $event.message">
|
|
36
|
+
<template v-if="$slots.settings" #default="settings"><slot name="settings" v-bind="settings" /></template>
|
|
37
|
+
</VoiceConversationSettings>
|
|
46
38
|
</template>
|
|
47
39
|
<v-alert v-if="state.error" type="error" density="compact" role="alert">{{ state.error }}</v-alert>
|
|
48
40
|
<v-alert v-if="state.nextTarget" type="info" class="voice-host__switch">
|
|
@@ -55,16 +47,26 @@ async function openText() {
|
|
|
55
47
|
</v-alert>
|
|
56
48
|
<div class="voice-host__body">
|
|
57
49
|
<slot name="conversation" :binding="state.binding" :session="state.session" :disabled="state.busy">
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
50
|
+
<VoiceConversation
|
|
51
|
+
:key="state.binding.id" :session="state.session" :disabled="state.busy"
|
|
52
|
+
:adapter="state.binding.adapter || null" :show-avatar="state.binding.showAvatar !== false"
|
|
53
|
+
:avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)"
|
|
54
|
+
>
|
|
55
|
+
<template v-if="$slots.avatar" #avatar="visual"><slot name="avatar" v-bind="visual" :binding="state.binding" /></template>
|
|
56
|
+
<template v-if="$slots['avatar-control']" #avatar-control="scope"><slot name="avatar-control" v-bind="scope" :binding="state.binding" :session="state.session" /></template>
|
|
57
|
+
<template v-if="presentation === 'inline' || state.binding.adapter" #settings-control>
|
|
58
|
+
<VoiceConversationSettings :voice="state.session.voice" compact @error="state.error = $event.message">
|
|
59
|
+
<template v-if="$slots.settings" #default="settings"><slot name="settings" v-bind="settings" /></template>
|
|
60
|
+
</VoiceConversationSettings>
|
|
61
|
+
</template>
|
|
62
|
+
<template #work-control>
|
|
63
|
+
<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)" />
|
|
64
|
+
</template>
|
|
65
|
+
</VoiceConversation>
|
|
64
66
|
</slot>
|
|
65
67
|
</div>
|
|
66
68
|
</ConversationDialog>
|
|
67
|
-
<template v-if="state.session && !state.visible">
|
|
69
|
+
<template v-if="presentation === 'dialog' && state.session && !state.visible">
|
|
68
70
|
<slot v-if="$slots.reopen" name="reopen" :controller="controller" />
|
|
69
71
|
<v-btn v-else class="voice-host__reopen" color="primary" :prepend-icon="mdiHeadset" min-height="56" @click="controller.reveal()">
|
|
70
72
|
{{ state.binding.label || 'Assistant' }} · Voice chat
|
|
@@ -76,6 +78,5 @@ async function openText() {
|
|
|
76
78
|
.voice-host__body { display: flex; flex: 1; min-height: 0; overflow: hidden; }
|
|
77
79
|
.voice-host__switch-actions { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
78
80
|
.voice-host__switch { flex: 0 0 auto; }
|
|
79
|
-
.voice-host__settings { width: 320px; max-width: calc(100vw - 32px); }
|
|
80
81
|
.voice-host__reopen { position: fixed; right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); max-width: calc(100vw - 24px); z-index: 1900; }
|
|
81
82
|
</style>
|