@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 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; resume opens a fresh recording.
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. Captions,
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.204",
13
- "@jskit-ai/assistant-runtime": "0.1.197",
14
- "@jskit-ai/assistant-voice": "0.1.4",
15
- "@jskit-ai/connectors-catalog": "0.1.43",
16
- "@jskit-ai/http-runtime": "0.1.224",
17
- "@jskit-ai/kernel": "0.1.226",
18
- "@jskit-ai/realtime": "0.1.223",
19
- "@jskit-ai/shell-web": "0.1.230",
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.4",
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.204",
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
- <v-dialog
33
- class="conversation-dialog" :activator="activator" :open-on-click="false"
34
- :model-value="modelValue" :transition="false" :fullscreen="xs" :width="xs ? '100%' : 620" :max-width="xs ? '100%' : 'calc(100% - 32px)'"
35
- :height="xs ? '100%' : 860" :max-height="xs ? '100%' : 'calc(100% - 32px)'"
36
- :content-props="{ style: { margin: xs ? 0 : '16px' } }" :aria-label="`${title} conversation`" :aria-hidden="!modelValue"
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
- <v-card class="conversation-dialog__surface" :rounded="xs ? 0 : 'lg'">
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
- </v-card>
52
- </v-dialog>
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.hasUnsentSpeech.value }
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 #composer-tools><div ref="toolsTarget" class="assistant-voice-conversation__tools" /></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"><template #work-control><slot name="work-control" /></template></VoiceConversationControls>
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
- toolsTarget: { type: Object, default: null }
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 && !sending.value && !pendingTranscript.value &&
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
- <p v-if="error || voice.error.value" class="assistant-voice__error" role="alert">{{ error || voice.error.value }}</p>
40
- <p v-if="controlStatus" class="assistant-voice-controls__status text-body-small" role="status" :title="callStatus">{{ controlStatus }}</p>
41
- <p v-if="compact && heldTranscript && !pendingTranscript" class="assistant-voice-controls__provisional" role="status" aria-label="Recognized words">Not sent · {{ heldTranscript }}</p>
42
- <p v-if="readAloud && voice.playbackBlocked?.value" class="assistant-voice-controls__status" role="status">Sound is blocked. Enable sound to hear future replies.</p>
43
- <v-btn v-if="readAloud && voice.playbackBlocked?.value" min-height="48" @click="enableSound">Enable sound</v-btn>
44
- <section v-if="pendingTranscript && (pendingTranscript.reviewBeforeSend || !sending)" class="assistant-voice__review" aria-label="Review voice message">
45
- <v-textarea
46
- :model-value="pendingTranscript.text" label="Review your message" :disabled="sending"
47
- :density="compact ? 'compact' : 'default'" :rows="compact ? 1 : 2" :max-rows="compact ? 2 : 3"
48
- auto-grow hide-details @update:model-value="editTranscript"
49
- />
50
- <div class="assistant-voice__review-actions">
51
- <v-btn :disabled="sending" min-height="48" @click="discardHeldRecording">Discard</v-btn>
52
- <v-btn :disabled="sending || !pendingTranscript.text.trim()" min-height="48" color="primary" @click="deliverTranscript">{{ sending ? 'Sending…' : 'Send' }}</v-btn>
53
- </div>
54
- </section>
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
- <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" />
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
- :prepend-icon="voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff" :aria-pressed="handsFreeListening || pushHolding" :aria-describedby="gestureHint"
63
- :disabled="!canTalk"
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
- {{ talkLabel }}
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: 4px; }
96
- .assistant-voice-controls__tools--compact .assistant-voice__talk-action { display: flex; padding: 0; width: auto; gap: 4px; }
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-controls--compact .assistant-voice__review { max-height: 128px; padding: 0; }
99
- .assistant-voice-controls--compact .assistant-voice__error { margin: 0; padding: 0; }
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 { mdiCog, mdiHeadset, mdiStop } from "@mdi/js";
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({ controller: { type: Object, required: true }, activator: { type: Object, default: null }, avatarSize: { type: String, default: "compact" } });
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
- <v-menu :close-on-content-click="false" location="bottom end" @update:model-value="open => open && !$slots.settings && invoke(() => state.session.voice.connect())">
31
- <template #activator="{ props: settingsButton }">
32
- <v-btn v-bind="settingsButton" :icon="mdiCog" variant="text" aria-label="Voice settings" title="Voice settings" />
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>
@@ -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 || pendingTranscript.value || sending.value) return;
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 && !pendingTranscript.value && !sending.value) await startRecording(false, true);
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 || pendingTranscript.value || sending.value || committing) return;
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 || pendingTranscript.value || sending.value || committing) return;
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 || pendingTranscript.value || sending.value) return;
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
- watch([pendingTranscript, voice.captureState, voice.partialTranscript], () => {
612
+ function currentTranscript() {
563
613
  const pending = pendingTranscript.value;
564
- const transcript = pending ? { id: pending.messageId, text: pending.text }
565
- : recording && ["listening", "transcribing"].includes(voice.captureState.value) && voice.partialTranscript.value
566
- ? { id: recording.messageId, text: voice.partialTranscript.value } : null;
567
- binding.onTranscript?.(transcript);
568
- }, { immediate: true });
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
  }