@jskit-ai/assistant-voice 0.1.4 → 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 +19 -4
- package/src/client/VoiceConversationControls.vue +80 -34
- package/src/client/VoiceConversationHost.vue +20 -25
- package/src/client/VoiceConversationSettings.vue +52 -0
- package/src/client/index.js +1 -0
- package/src/client/voiceConversation.js +131 -22
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>
|
|
@@ -3,6 +3,7 @@ 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 },
|
|
@@ -15,7 +16,7 @@ const { targetLabel, voice, microphoneMuted, heldTranscript, pendingTranscript,
|
|
|
15
16
|
avatarVisual, callAudioLevel, voiceWords, voiceAnswer } = props.session;
|
|
16
17
|
const presentationAdapter = computed(() => props.adapter && ({
|
|
17
18
|
...props.adapter,
|
|
18
|
-
composer: { ...props.adapter.composer, canSend: props.adapter.composer?.canSend && !props.session.
|
|
19
|
+
composer: { ...props.adapter.composer, canSend: props.adapter.composer?.canSend && !props.session.composerBlocked.value }
|
|
19
20
|
}));
|
|
20
21
|
const toolsTarget = ref(null);
|
|
21
22
|
const voiceWordsTarget = ref(null);
|
|
@@ -38,10 +39,21 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
|
|
|
38
39
|
<VoiceAvatar v-bind="avatarVisual" />
|
|
39
40
|
</slot>
|
|
40
41
|
</template>
|
|
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>
|
|
42
53
|
<template #composer-feedback>
|
|
43
|
-
<VoiceConversationControls :session="session" :disabled="disabled" compact :tools-target="toolsTarget">
|
|
54
|
+
<VoiceConversationControls :session="session" :disabled="disabled" compact :icon-only="showAvatar" :tools-target="toolsTarget" :review-in-transcript="Boolean(presentationAdapter?.conversation?.previewMessage)">
|
|
44
55
|
<template #work-control><slot name="work-control" /></template>
|
|
56
|
+
<template #settings-control><slot name="settings-control" /></template>
|
|
45
57
|
</VoiceConversationControls>
|
|
46
58
|
</template>
|
|
47
59
|
</AssistantConversationElement>
|
|
@@ -66,7 +78,10 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
|
|
|
66
78
|
<p class="assistant-voice__caption-text text-body-medium">{{ voiceAnswer }}</p>
|
|
67
79
|
</section>
|
|
68
80
|
</div>
|
|
69
|
-
<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>
|
|
70
85
|
</section>
|
|
71
86
|
</fieldset>
|
|
72
87
|
</template>
|
|
@@ -7,7 +7,10 @@ const props = defineProps({
|
|
|
7
7
|
session: { type: Object, required: true },
|
|
8
8
|
disabled: { type: Boolean, default: false },
|
|
9
9
|
compact: { type: Boolean, default: false },
|
|
10
|
-
|
|
10
|
+
iconOnly: { type: Boolean, default: false },
|
|
11
|
+
reviewInTranscript: { type: Boolean, default: false },
|
|
12
|
+
toolsTarget: { type: Object, default: null },
|
|
13
|
+
feedbackTarget: { type: Object, default: null }
|
|
11
14
|
});
|
|
12
15
|
const {
|
|
13
16
|
targetLabel, voice, error, microphoneMuted, live, starting, callMode,
|
|
@@ -17,13 +20,14 @@ const {
|
|
|
17
20
|
deliverTranscript, discardHeldRecording, editTranscript
|
|
18
21
|
} = props.session;
|
|
19
22
|
const controlStatus = computed(() => {
|
|
23
|
+
if (props.iconOnly && voice.listening.value && !starting.value && !error.value && !voice.error.value) return "";
|
|
20
24
|
if (!props.compact || !props.session.hasUnsentSpeech.value) return callStatus.value;
|
|
21
|
-
if (pendingTranscript.value) return "Send or discard speech to send text.";
|
|
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…";
|
|
22
27
|
return voice.listening.value ? "Recording · finish speech to send text." : "Finish speech to send text.";
|
|
23
28
|
});
|
|
24
|
-
const handsFreeListening = computed(() => live.value && callMode.value === "hands-free" && !microphoneMuted.value);
|
|
25
|
-
const canTalk = computed(() => !props.disabled && !
|
|
26
|
-
(!capturing.value || voice.listening.value || pushHolding.value || starting.value));
|
|
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));
|
|
27
31
|
const talkLabel = computed(() => starting.value ? "Connecting…" : pushHolding.value ? "Release to send" : handsFreeListening.value ? "Pause" : "Talk");
|
|
28
32
|
const gestureHint = useId();
|
|
29
33
|
const gesture = useVoiceLauncher({
|
|
@@ -36,42 +40,63 @@ const gesture = useVoiceLauncher({
|
|
|
36
40
|
|
|
37
41
|
<template>
|
|
38
42
|
<fieldset class="assistant-voice-controls" :class="{ 'assistant-voice-controls--compact': compact }" :disabled="disabled">
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
<v-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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>
|
|
55
68
|
<Teleport :to="toolsTarget" :disabled="!toolsTarget">
|
|
56
|
-
<fieldset class="assistant-voice-controls__tools" :class="{ 'assistant-voice-controls__tools--compact': compact }" :disabled="disabled" :aria-label="`${targetLabel} voice chat controls`">
|
|
57
|
-
<
|
|
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">
|
|
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" />
|
|
72
|
+
</div>
|
|
58
73
|
<slot name="work-control" />
|
|
59
74
|
<div class="assistant-voice__talk-action">
|
|
60
75
|
<v-btn
|
|
61
|
-
class="assistant-voice__talk" color="primary" :variant="handsFreeListening ? 'tonal' : 'flat'" :min-height="compact ? 48 : 64" rounded="xl"
|
|
62
|
-
:
|
|
63
|
-
:
|
|
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"
|
|
64
83
|
@pointerdown="gesture.pointerDown" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel"
|
|
65
84
|
@keydown.space.prevent="gesture.keyDown" @keyup.space.prevent="gesture.keyUp"
|
|
66
85
|
@keydown.enter.prevent="gesture.keyDown" @keyup.enter.prevent="gesture.keyUp" @blur="gesture.cancel" @contextmenu.prevent @click="gesture.click"
|
|
67
86
|
>
|
|
68
|
-
|
|
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>
|
|
69
89
|
</v-btn>
|
|
70
90
|
<v-btn
|
|
71
|
-
:icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" variant="tonal" :min-height="compact ? 48 : 64" :min-width="compact ? 48 : 64" rounded="xl"
|
|
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"
|
|
72
92
|
:aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
|
|
73
93
|
:title="readAloud ? 'Spoken replies on' : 'Spoken replies off'" @click="toggleReadAloud"
|
|
74
|
-
|
|
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" />
|
|
75
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>
|
|
76
101
|
</div>
|
|
77
102
|
</fieldset>
|
|
@@ -80,11 +105,18 @@ const gesture = useVoiceLauncher({
|
|
|
80
105
|
</template>
|
|
81
106
|
|
|
82
107
|
<style scoped>
|
|
83
|
-
.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; }
|
|
84
109
|
.assistant-voice-controls { flex: 0 0 auto; }
|
|
85
110
|
.assistant-voice-controls--compact { flex: 1 1 100%; width: 100%; }
|
|
86
111
|
.assistant-voice-controls__tools { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; }
|
|
87
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
|
+
}
|
|
88
120
|
.assistant-voice-controls__provisional { margin: 0; max-height: 3rem; overflow-y: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
89
121
|
.assistant-voice__talk-action { width: min(288px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 6px 8px; padding: 16px; }
|
|
90
122
|
.assistant-voice__talk { touch-action: none; user-select: none; -webkit-touch-callout: none; }
|
|
@@ -92,10 +124,24 @@ const gesture = useVoiceLauncher({
|
|
|
92
124
|
.assistant-voice__review { flex: 0 0 auto; max-height: 160px; overflow-y: auto; padding: 8px 16px; }
|
|
93
125
|
.assistant-voice__review-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
94
126
|
.assistant-voice__error { padding: 8px 16px; color: rgb(var(--v-theme-error)); }
|
|
95
|
-
.assistant-voice-controls__tools--compact { justify-content: flex-start; gap:
|
|
96
|
-
.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; }
|
|
97
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; }
|
|
98
|
-
.assistant-voice-
|
|
99
|
-
.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; }
|
|
100
146
|
@media (max-height: 650px) { .assistant-voice__talk-action { padding-top: 8px; } }
|
|
101
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,31 +25,16 @@ async function openText() {
|
|
|
20
25
|
|
|
21
26
|
<template>
|
|
22
27
|
<ConversationDialog
|
|
23
|
-
v-if="state.session" :model-value="state.visible" :activator="activator"
|
|
28
|
+
v-if="state.session" :model-value="state.visible" :activator="activator" :presentation="presentation"
|
|
24
29
|
:title="state.binding.label || 'Assistant'" mode="talk"
|
|
25
30
|
:show-modes="Boolean(state.binding.openText && !state.binding.adapter)"
|
|
26
|
-
minimizable close-label="Close voice chat" @minimize="controller.minimize()" @update:model-value="invoke(() => controller.end({ discard: true }))"
|
|
31
|
+
:minimizable="presentation === 'dialog'" close-label="Close voice chat" @minimize="controller.minimize()" @update:model-value="invoke(() => controller.end({ discard: true }))"
|
|
27
32
|
@update:mode="value => value === 'text' && invoke(openText)"
|
|
28
33
|
>
|
|
29
|
-
<template #header-actions>
|
|
30
|
-
<
|
|
31
|
-
<template #
|
|
32
|
-
|
|
33
|
-
</template>
|
|
34
|
-
<v-card class="voice-host__settings" role="region" aria-label="Voice settings">
|
|
35
|
-
<v-card-text>
|
|
36
|
-
<slot name="settings" :voice="state.session.voice">
|
|
37
|
-
<v-select
|
|
38
|
-
v-model="state.session.voice.selectedVoice.value"
|
|
39
|
-
:items="state.session.voice.availableVoices.value" item-title="label" item-value="id"
|
|
40
|
-
:disabled="state.session.voice.availableVoices.value.length < 2"
|
|
41
|
-
label="Speaking voice" density="comfortable"
|
|
42
|
-
hint="Applies to the next spoken reply" persistent-hint
|
|
43
|
-
/>
|
|
44
|
-
</slot>
|
|
45
|
-
</v-card-text>
|
|
46
|
-
</v-card>
|
|
47
|
-
</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>
|
|
48
38
|
</template>
|
|
49
39
|
<v-alert v-if="state.error" type="error" density="compact" role="alert">{{ state.error }}</v-alert>
|
|
50
40
|
<v-alert v-if="state.nextTarget" type="info" class="voice-host__switch">
|
|
@@ -63,6 +53,12 @@ async function openText() {
|
|
|
63
53
|
:avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)"
|
|
64
54
|
>
|
|
65
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>
|
|
66
62
|
<template #work-control>
|
|
67
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)" />
|
|
68
64
|
</template>
|
|
@@ -70,7 +66,7 @@ async function openText() {
|
|
|
70
66
|
</slot>
|
|
71
67
|
</div>
|
|
72
68
|
</ConversationDialog>
|
|
73
|
-
<template v-if="state.session && !state.visible">
|
|
69
|
+
<template v-if="presentation === 'dialog' && state.session && !state.visible">
|
|
74
70
|
<slot v-if="$slots.reopen" name="reopen" :controller="controller" />
|
|
75
71
|
<v-btn v-else class="voice-host__reopen" color="primary" :prepend-icon="mdiHeadset" min-height="56" @click="controller.reveal()">
|
|
76
72
|
{{ state.binding.label || 'Assistant' }} · Voice chat
|
|
@@ -82,6 +78,5 @@ async function openText() {
|
|
|
82
78
|
.voice-host__body { display: flex; flex: 1; min-height: 0; overflow: hidden; }
|
|
83
79
|
.voice-host__switch-actions { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
84
80
|
.voice-host__switch { flex: 0 0 auto; }
|
|
85
|
-
.voice-host__settings { width: 320px; max-width: calc(100vw - 32px); }
|
|
86
81
|
.voice-host__reopen { position: fixed; right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); max-width: calc(100vw - 24px); z-index: 1900; }
|
|
87
82
|
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { mdiCog } from "@mdi/js";
|
|
3
|
+
import { useSlots } from "vue";
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
voice: { type: Object, default: null },
|
|
7
|
+
compact: Boolean
|
|
8
|
+
});
|
|
9
|
+
const emit = defineEmits(["error"]);
|
|
10
|
+
const slots = useSlots();
|
|
11
|
+
function selectVoice(value) {
|
|
12
|
+
const selectedVoice = props.voice.selectedVoice;
|
|
13
|
+
selectedVoice.value = value;
|
|
14
|
+
}
|
|
15
|
+
async function openSettings(open) {
|
|
16
|
+
if (!open || slots.default || !props.voice) return;
|
|
17
|
+
try { await props.voice.connect(); }
|
|
18
|
+
catch (error) { emit("error", error); }
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<v-menu :close-on-content-click="false" location="bottom end" @update:model-value="openSettings">
|
|
24
|
+
<template #activator="{ props: settingsButton }">
|
|
25
|
+
<v-btn
|
|
26
|
+
v-bind="settingsButton" :icon="mdiCog" variant="text" :color="compact ? 'primary' : undefined"
|
|
27
|
+
:width="compact ? 40 : undefined" :min-width="compact ? 40 : 48" :min-height="compact ? 44 : 48" :disabled="!voice && !$slots.default"
|
|
28
|
+
aria-label="Voice settings" title="Voice settings"
|
|
29
|
+
>
|
|
30
|
+
<template v-if="compact" #default><span class="voice-settings__disc"><v-icon size="20" :icon="mdiCog" /></span></template>
|
|
31
|
+
</v-btn>
|
|
32
|
+
</template>
|
|
33
|
+
<v-card class="voice-settings__menu" role="region" aria-label="Voice settings">
|
|
34
|
+
<v-card-text>
|
|
35
|
+
<slot :voice="voice">
|
|
36
|
+
<v-select
|
|
37
|
+
v-if="voice" :model-value="voice.selectedVoice.value" @update:model-value="selectVoice"
|
|
38
|
+
:items="voice.availableVoices.value" item-title="label" item-value="id"
|
|
39
|
+
:disabled="voice.availableVoices.value.length < 2"
|
|
40
|
+
label="Speaking voice" density="comfortable"
|
|
41
|
+
hint="Applies to the next spoken reply" persistent-hint
|
|
42
|
+
/>
|
|
43
|
+
</slot>
|
|
44
|
+
</v-card-text>
|
|
45
|
+
</v-card>
|
|
46
|
+
</v-menu>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<style scoped>
|
|
50
|
+
.voice-settings__menu { width: 320px; max-width: calc(100vw - 32px); }
|
|
51
|
+
.voice-settings__disc { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: rgba(var(--v-theme-primary), .12); }
|
|
52
|
+
</style>
|
package/src/client/index.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
6
|
export { default as VoiceConversationControls } from "./VoiceConversationControls.vue";
|
|
7
|
+
export { default as VoiceConversationSettings } from "./VoiceConversationSettings.vue";
|
|
7
8
|
|
|
8
9
|
export { useVoiceLauncher } from "./voiceLauncher.js";
|
|
9
10
|
export { useVoiceAvatar } from "./voiceAvatar.js";
|
|
@@ -15,6 +15,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
15
15
|
const heldReview = ref(false);
|
|
16
16
|
const holding = ref(false);
|
|
17
17
|
const sending = ref(false);
|
|
18
|
+
const takingTranscript = ref(false);
|
|
18
19
|
const live = ref(false);
|
|
19
20
|
const starting = ref(false);
|
|
20
21
|
const callMode = ref(binding.defaults?.mode === "hands-free" ? "hands-free" : "push-to-talk");
|
|
@@ -263,6 +264,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
263
264
|
startupAbort?.abort();
|
|
264
265
|
startupAbort = null;
|
|
265
266
|
starting.value = false;
|
|
267
|
+
releaseEditCapturePause();
|
|
266
268
|
recording = null;
|
|
267
269
|
await voice.cancelListening();
|
|
268
270
|
}
|
|
@@ -273,11 +275,21 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
273
275
|
await cancelRecording();
|
|
274
276
|
}
|
|
275
277
|
function dismissTranscript() {
|
|
278
|
+
releaseEditCapturePause();
|
|
276
279
|
pendingTranscript.value = null;
|
|
277
280
|
heldReview.value = false;
|
|
278
281
|
holding.value = false;
|
|
279
282
|
error.value = "";
|
|
280
283
|
}
|
|
284
|
+
function releaseEditCapturePause() {
|
|
285
|
+
const pending = pendingTranscript.value;
|
|
286
|
+
const captureId = pending?.editPausedCaptureId;
|
|
287
|
+
if (!captureId) return;
|
|
288
|
+
delete pending.editPausedCaptureId;
|
|
289
|
+
if (recording?.messageId === captureId && !disposed && live.value && callMode.value === "hands-free") {
|
|
290
|
+
voice.setMicrophoneMuted(false);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
281
293
|
async function talk() {
|
|
282
294
|
error.value = "";
|
|
283
295
|
try {
|
|
@@ -288,7 +300,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
288
300
|
} catch (cause) { recording = null; error.value = cause.message; }
|
|
289
301
|
}
|
|
290
302
|
async function startRecording(reviewBeforeSend, continuous = false) {
|
|
291
|
-
if (disposed || starting.value || microphoneMuted.value || capturing.value ||
|
|
303
|
+
if (disposed || starting.value || microphoneMuted.value || capturing.value || committing ||
|
|
304
|
+
(pendingTranscript.value || sending.value) && !(continuous && live.value && (callMode.value === "hands-free" || pushHolding.value))) return;
|
|
292
305
|
error.value = "";
|
|
293
306
|
reviewBeforeSend ||= binding.defaults?.reviewBeforeSend === true;
|
|
294
307
|
heldReview.value = reviewBeforeSend;
|
|
@@ -302,10 +315,13 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
302
315
|
} catch (cause) { if (recording === next) { recording = null; error.value = cause.message; } }
|
|
303
316
|
}
|
|
304
317
|
async function toggleMicrophoneMuted() {
|
|
318
|
+
// A person's explicit microphone choice supersedes an Edit-owned pause.
|
|
319
|
+
if (pendingTranscript.value) delete pendingTranscript.value.editPausedCaptureId;
|
|
305
320
|
voice.setMicrophoneMuted(!microphoneMuted.value);
|
|
321
|
+
if (!microphoneMuted.value && recording) delete recording.finishAfterPending;
|
|
306
322
|
clearTimeout(stopTimer);
|
|
307
323
|
if (microphoneMuted.value && starting.value) await cancelRecording();
|
|
308
|
-
else if (!microphoneMuted.value && live.value && callMode.value === "hands-free" && !capturing.value
|
|
324
|
+
else if (!microphoneMuted.value && live.value && callMode.value === "hands-free" && !capturing.value) await startRecording(false, true);
|
|
309
325
|
}
|
|
310
326
|
async function changeCallMode(mode) {
|
|
311
327
|
if (!["push-to-talk", "hands-free"].includes(mode) || mode === callMode.value || callModeBusy.value || committing) return;
|
|
@@ -318,12 +334,13 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
318
334
|
}
|
|
319
335
|
async function startPushToTalk(event) {
|
|
320
336
|
if (event?.repeat || event?.isPrimary === false || event?.button !== undefined && event.button !== 0 || pushHolding.value || disposed || starting.value
|
|
321
|
-
|| capturing.value && !voice.listening.value ||
|
|
337
|
+
|| capturing.value && !voice.listening.value || committing) return;
|
|
322
338
|
if (event?.pointerId !== undefined) event.currentTarget.setPointerCapture(event.pointerId);
|
|
323
339
|
pushInput = event?.pointerId ?? event?.key ?? null;
|
|
324
340
|
pushHolding.value = true;
|
|
325
341
|
callMode.value = "push-to-talk";
|
|
326
342
|
voice.setMicrophoneMuted(false);
|
|
343
|
+
if (recording) delete recording.finishAfterPending;
|
|
327
344
|
if (!live.value) await toggleLive();
|
|
328
345
|
if (!pushHolding.value || !live.value || disposed) return;
|
|
329
346
|
// A hold can take over an open hands-free microphone without throwing away
|
|
@@ -332,7 +349,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
332
349
|
recording.continuous = false;
|
|
333
350
|
return;
|
|
334
351
|
}
|
|
335
|
-
await startRecording(false);
|
|
352
|
+
await startRecording(false, Boolean(pendingTranscript.value || sending.value));
|
|
336
353
|
if (!recording) pushHolding.value = false;
|
|
337
354
|
}
|
|
338
355
|
async function finishPushToTalk(event) {
|
|
@@ -340,6 +357,11 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
340
357
|
pushHolding.value = false;
|
|
341
358
|
pushInput = null;
|
|
342
359
|
try {
|
|
360
|
+
if (voice.listening.value && (pendingTranscript.value || sending.value || committing)) {
|
|
361
|
+
if (recording) recording.finishAfterPending = true;
|
|
362
|
+
voice.setMicrophoneMuted(true);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
343
365
|
if (voice.listening.value) await voice.stopListening();
|
|
344
366
|
else await cancelRecording();
|
|
345
367
|
} catch (cause) { await cancelRecording(); error.value = cause.message; }
|
|
@@ -351,7 +373,15 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
351
373
|
await cancelRecording();
|
|
352
374
|
}
|
|
353
375
|
async function toggleHandsFree() {
|
|
354
|
-
if (disposed || pushHolding.value
|
|
376
|
+
if (disposed || pushHolding.value) return;
|
|
377
|
+
if (live.value && callMode.value === "hands-free" && voice.listening.value && (pendingTranscript.value || sending.value || committing)) {
|
|
378
|
+
// Pause must finish the newer recording after the existing admission,
|
|
379
|
+
// without replacing the one pending transcript or stopping output.
|
|
380
|
+
if (!microphoneMuted.value && recording) recording.finishAfterPending = true;
|
|
381
|
+
await toggleMicrophoneMuted();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (committing) return;
|
|
355
385
|
if (starting.value) { await cancelRecording(); return; }
|
|
356
386
|
if (capturing.value && !voice.listening.value) return;
|
|
357
387
|
if (live.value && callMode.value === "hands-free") {
|
|
@@ -360,10 +390,16 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
360
390
|
// final sound is included; resuming starts a fresh recording.
|
|
361
391
|
await voice.stopListening();
|
|
362
392
|
voice.setMicrophoneMuted(true);
|
|
363
|
-
} else await toggleMicrophoneMuted();
|
|
393
|
+
} else if (microphoneMuted.value) await toggleMicrophoneMuted();
|
|
394
|
+
else await startRecording(false, true);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (callMode.value === "hands-free" && (pendingTranscript.value || sending.value)) {
|
|
398
|
+
voice.setMicrophoneMuted(false);
|
|
399
|
+
await toggleLive();
|
|
364
400
|
return;
|
|
365
401
|
}
|
|
366
|
-
if (callModeBusy.value) return;
|
|
402
|
+
if (pendingTranscript.value || sending.value || callModeBusy.value) return;
|
|
367
403
|
voice.setMicrophoneMuted(false);
|
|
368
404
|
await changeCallMode("hands-free");
|
|
369
405
|
if (!live.value) await toggleLive();
|
|
@@ -379,7 +415,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
379
415
|
await cancelRecording();
|
|
380
416
|
return;
|
|
381
417
|
}
|
|
382
|
-
if (disposed || microphoneMuted.value || capturing.value ||
|
|
418
|
+
if (disposed || microphoneMuted.value || capturing.value || committing ||
|
|
419
|
+
(pendingTranscript.value || sending.value) && callMode.value !== "hands-free" && !pushHolding.value) return;
|
|
383
420
|
const controller = new AbortController();
|
|
384
421
|
startupAbort = controller;
|
|
385
422
|
starting.value = true;
|
|
@@ -415,7 +452,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
415
452
|
}
|
|
416
453
|
function decideTurn() {
|
|
417
454
|
const candidate = voice.endpoint.value;
|
|
418
|
-
if (!live.value || callMode.value !== "hands-free" || !voice.listening.value || microphoneMuted.value || pendingTranscript.value || sending.value || !candidate) return;
|
|
455
|
+
if (takingTranscript.value || !live.value || callMode.value !== "hands-free" || !voice.listening.value || microphoneMuted.value || pendingTranscript.value || sending.value || !candidate) return;
|
|
419
456
|
const key = `${candidate.turnId}:${candidate.revision}`;
|
|
420
457
|
if (decidedCandidate === key) return;
|
|
421
458
|
decidedCandidate = key;
|
|
@@ -441,6 +478,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
441
478
|
}
|
|
442
479
|
committing = { ...captured, turnId: candidate.turnId, revision: candidate.revision };
|
|
443
480
|
if (!voice.finishUtterance(candidate)) { committing = null; return; }
|
|
481
|
+
publishTranscript();
|
|
444
482
|
// The daemon rechecks the endpoint revision before returning final text.
|
|
445
483
|
// Ordinary steering leaves the current answer and queued audio intact.
|
|
446
484
|
} catch (cause) {
|
|
@@ -451,7 +489,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
451
489
|
}
|
|
452
490
|
}
|
|
453
491
|
}
|
|
454
|
-
watch([voice.endpoint, pendingTranscript, sending], decideTurn);
|
|
492
|
+
watch([voice.endpoint, pendingTranscript, sending, takingTranscript], decideTurn);
|
|
455
493
|
watch(voice.completedUtterance, (utterance) => {
|
|
456
494
|
if (!utterance || !live.value || !committing || committing.turnId !== utterance.turnId || committing.revision !== utterance.revision) return;
|
|
457
495
|
pendingTranscript.value = { ...committing, text: utterance.text };
|
|
@@ -462,7 +500,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
462
500
|
// A discard can lose a race with resumed speech. Keep this utterance's
|
|
463
501
|
// identity and destination until the daemon acknowledges the boundary.
|
|
464
502
|
if (reset?.turnId !== voice.activeListenTurnId.value || !live.value || !recording) return;
|
|
465
|
-
recording = { focus: { ...(binding.captureContext?.() || {}) }, messageId: crypto.randomUUID(), started: true, continuous: true
|
|
503
|
+
recording = { focus: { ...(binding.captureContext?.() || {}) }, messageId: crypto.randomUUID(), started: true, continuous: true,
|
|
504
|
+
...(recording.finishAfterPending ? { finishAfterPending: true } : {}) };
|
|
466
505
|
});
|
|
467
506
|
watch(voice.partialTranscript, (text, previous) => {
|
|
468
507
|
clearTimeout(stopTimer);
|
|
@@ -473,7 +512,16 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
473
512
|
if (live.value && !microphoneMuted.value && voice.partialTranscript.value === text) stopSpeech();
|
|
474
513
|
}, 120);
|
|
475
514
|
});
|
|
476
|
-
watch([pendingTranscript, sending], () => {
|
|
515
|
+
watch([pendingTranscript, sending, voice.utteranceReset], () => {
|
|
516
|
+
if (!disposed && live.value && !pushHolding.value && !changingCallMode.value &&
|
|
517
|
+
!pendingTranscript.value && !sending.value && !committing && microphoneMuted.value &&
|
|
518
|
+
recording?.finishAfterPending && voice.listening.value) {
|
|
519
|
+
// Retire intent before the asynchronous PCM drain so a receipt/reset
|
|
520
|
+
// cannot finalize this capture twice. Its original final watcher sends B.
|
|
521
|
+
delete recording.finishAfterPending;
|
|
522
|
+
void voice.stopListening().catch(cause => { error.value = cause.message; });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
477
525
|
if (live.value && callMode.value === "hands-free" && !changingCallMode.value && !microphoneMuted.value && !capturing.value && !pendingTranscript.value && !sending.value && !voice.error.value) void startRecording(false, true);
|
|
478
526
|
});
|
|
479
527
|
async function startHeldRecording() { await startRecording(true); }
|
|
@@ -524,10 +572,12 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
524
572
|
text: binding.state.streamingReply?.text }), ({ messages = [], reply }) => {
|
|
525
573
|
if (!binding.id) return;
|
|
526
574
|
// Reconcile an uncertain send by identity, never by equal transcript text.
|
|
527
|
-
if (pendingTranscript.value && messages.some((message) => message.role === "user" && message.id === pendingTranscript.value.messageId)) {
|
|
575
|
+
if (pendingTranscript.value && messages.some((message) => message.role === "user" && message.id === pendingTranscript.value.messageId && message.receipt !== false)) {
|
|
528
576
|
dismissTranscript();
|
|
529
577
|
}
|
|
530
578
|
for (const message of messages) {
|
|
579
|
+
// Unadmitted local records do not acknowledge a request or lift speech silence.
|
|
580
|
+
if (message.role === "user" && message.receipt === false) continue;
|
|
531
581
|
if (seen.has(message.id)) continue;
|
|
532
582
|
if (message.role === "user" && message.id === speechInvitation) {
|
|
533
583
|
speechSuppressed = false;
|
|
@@ -559,13 +609,69 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
559
609
|
watch(voice.error, (message) => { if (message) cancelSpeech("playback-error", "failed"); });
|
|
560
610
|
watch(() => error.value || voice.error.value, (message) => { if (message) binding.onError?.(message); });
|
|
561
611
|
watch([voice.activeSpeechTurnId, voice.canAppendSpeech], () => { void drainSpeech(); });
|
|
562
|
-
|
|
612
|
+
function currentTranscript() {
|
|
563
613
|
const pending = pendingTranscript.value;
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
614
|
+
const currentWords = recording && ["listening", "transcribing"].includes(voice.captureState.value)
|
|
615
|
+
&& voice.partialTranscript.value && recording.messageId !== pending?.messageId;
|
|
616
|
+
return currentWords ? { id: recording.messageId, text: voice.partialTranscript.value }
|
|
617
|
+
: pending ? { id: pending.messageId, text: pending.text } : null;
|
|
618
|
+
}
|
|
619
|
+
function canTakeTranscript(messageId) {
|
|
620
|
+
return !disposed && !starting.value && !sending.value && !committing && !takingTranscript.value
|
|
621
|
+
&& (pendingTranscript.value?.messageId === messageId || currentTranscript()?.id === messageId)
|
|
622
|
+
&& !binding.state.messages?.some(message => message.role === "user" && message.id === messageId && message.receipt !== false);
|
|
623
|
+
}
|
|
624
|
+
async function beginTranscriptEdit(messageId) {
|
|
625
|
+
if (!canTakeTranscript(messageId)) return false;
|
|
626
|
+
const pending = pendingTranscript.value;
|
|
627
|
+
if (pending && pending.messageId !== messageId) return false;
|
|
628
|
+
const selected = pending ? { id: pending.messageId, text: pending.text } : currentTranscript();
|
|
629
|
+
const capture = recording;
|
|
630
|
+
takingTranscript.value = true;
|
|
631
|
+
try {
|
|
632
|
+
// Retain the selected words and destination before cancelling capture.
|
|
633
|
+
// The existing pending-clear watcher resumes hands-free after resolution.
|
|
634
|
+
pendingTranscript.value = {
|
|
635
|
+
...(pending?.messageId === messageId ? pending : capture),
|
|
636
|
+
messageId, text: selected.text, reviewBeforeSend: true, editing: true
|
|
637
|
+
};
|
|
638
|
+
if (capture) {
|
|
639
|
+
if (capture.messageId !== messageId && live.value && callMode.value === "hands-free") {
|
|
640
|
+
// Newer continuous words retain their recording identity and endpoint.
|
|
641
|
+
if (!microphoneMuted.value) {
|
|
642
|
+
pendingTranscript.value.editPausedCaptureId = capture.messageId;
|
|
643
|
+
voice.setMicrophoneMuted(true);
|
|
644
|
+
}
|
|
645
|
+
} else if (pushHolding.value) await cancelPushToTalk();
|
|
646
|
+
else await cancelRecording();
|
|
647
|
+
}
|
|
648
|
+
return true;
|
|
649
|
+
} finally { takingTranscript.value = false; }
|
|
650
|
+
}
|
|
651
|
+
async function takeTranscript(messageId, transfer) {
|
|
652
|
+
if (!canTakeTranscript(messageId)) return false;
|
|
653
|
+
const pending = pendingTranscript.value;
|
|
654
|
+
const selected = pending?.messageId === messageId ? { id: messageId, text: pending.text } : currentTranscript();
|
|
655
|
+
takingTranscript.value = true;
|
|
656
|
+
try {
|
|
657
|
+
// The host rechecks its draft and transfers synchronously before cancellation.
|
|
658
|
+
if (transfer && transfer(selected.text) !== true) return false;
|
|
659
|
+
if (pendingTranscript.value?.messageId === messageId) dismissTranscript();
|
|
660
|
+
else if (recording?.messageId === messageId) {
|
|
661
|
+
if (pushHolding.value) await cancelPushToTalk();
|
|
662
|
+
else await cancelRecording();
|
|
663
|
+
if (!disposed && live.value && callMode.value === "hands-free" && !microphoneMuted.value) {
|
|
664
|
+
await startRecording(false, true);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return true;
|
|
668
|
+
} finally { takingTranscript.value = false; }
|
|
669
|
+
}
|
|
670
|
+
function publishTranscript() {
|
|
671
|
+
const transcript = currentTranscript();
|
|
672
|
+
binding.onTranscript?.(transcript, { canTake: Boolean(transcript && canTakeTranscript(transcript.id)) });
|
|
673
|
+
}
|
|
674
|
+
watch([pendingTranscript, voice.captureState, voice.partialTranscript, voice.endpoint, sending, starting, takingTranscript], publishTranscript, { immediate: true });
|
|
569
675
|
watch(avatarVisual, (visual) => binding.onVisual?.(visual), { immediate: true });
|
|
570
676
|
const page = globalThis.window || globalThis;
|
|
571
677
|
const cancelStartup = () => { if (starting.value) void discardRecording(); else void cancelPushToTalk(); };
|
|
@@ -589,9 +695,12 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
589
695
|
stopSpeech();
|
|
590
696
|
await voice.close();
|
|
591
697
|
}
|
|
592
|
-
function editTranscript(text) {
|
|
593
|
-
if (pendingTranscript.value && !sending.value) pendingTranscript.value = { ...pendingTranscript.value, text: String(text) };
|
|
698
|
+
function editTranscript(text, messageId = pendingTranscript.value?.messageId) {
|
|
699
|
+
if (pendingTranscript.value && pendingTranscript.value.messageId === messageId && !sending.value) pendingTranscript.value = { ...pendingTranscript.value, text: String(text) };
|
|
594
700
|
}
|
|
701
|
+
const composerBlocked = computed(() => starting.value ||
|
|
702
|
+
(!live.value || callMode.value !== "hands-free") &&
|
|
703
|
+
(sending.value || Boolean(pendingTranscript.value) || capturing.value));
|
|
595
704
|
const hasUnsentSpeech = computed(() => capturing.value || starting.value || sending.value || Boolean(pendingTranscript.value));
|
|
596
705
|
return {
|
|
597
706
|
oneOffTalkMode, targetLabel, voice, readAloud, error, pendingTranscript, heldReview, holding, sending,
|
|
@@ -601,6 +710,6 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
601
710
|
stopSpeech, inviteSpeech, toggleReadAloud, enableSound, soundPreparing, deliverTranscript, cancelRecording,
|
|
602
711
|
discardRecording, dismissTranscript, talk, toggleMicrophoneMuted, changeCallMode,
|
|
603
712
|
startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleLive, toggleHandsFree, startHeldRecording,
|
|
604
|
-
finishHeldRecording, discardHeldRecording, close, editTranscript, hasUnsentSpeech
|
|
713
|
+
finishHeldRecording, discardHeldRecording, close, editTranscript, canTakeTranscript, beginTranscriptEdit, takeTranscript, hasUnsentSpeech, composerBlocked
|
|
605
714
|
};
|
|
606
715
|
}
|