@jskit-ai/assistant-voice 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.203",
13
- "@jskit-ai/assistant-runtime": "0.1.196",
14
- "@jskit-ai/assistant-voice": "0.1.3",
15
- "@jskit-ai/connectors-catalog": "0.1.42",
16
- "@jskit-ai/http-runtime": "0.1.223",
17
- "@jskit-ai/kernel": "0.1.225",
18
- "@jskit-ai/realtime": "0.1.222",
19
- "@jskit-ai/shell-web": "0.1.229",
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",
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.203",
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>
@@ -1,17 +1,23 @@
1
1
  <script setup>
2
- import { ref, watch } from "vue";
2
+ import { computed, ref, watch } from "vue";
3
3
  import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
4
4
  import VoiceAvatar from "./VoiceAvatar.vue";
5
5
  import VoiceConversationControls from "./VoiceConversationControls.vue";
6
+ import { mdiMicrophone, mdiVolumeHigh } from "@mdi/js";
6
7
 
7
8
  const props = defineProps({
8
9
  session: { type: Object, required: true }, disabled: { type: Boolean, default: false },
9
10
  adapter: { type: Object, default: null },
11
+ showAvatar: { type: Boolean, default: true },
10
12
  avatarSize: { type: String, default: "compact" }
11
13
  });
12
14
  defineEmits(["update:avatarSize"]);
13
15
  const { targetLabel, voice, microphoneMuted, heldTranscript, pendingTranscript, sending,
14
16
  avatarVisual, callAudioLevel, voiceWords, voiceAnswer } = props.session;
17
+ const presentationAdapter = computed(() => props.adapter && ({
18
+ ...props.adapter,
19
+ composer: { ...props.adapter.composer, canSend: props.adapter.composer?.canSend && !props.session.composerBlocked.value }
20
+ }));
15
21
  const toolsTarget = ref(null);
16
22
  const voiceWordsTarget = ref(null);
17
23
  const voiceAnswerTarget = ref(null);
@@ -27,19 +33,37 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
27
33
 
28
34
  <template>
29
35
  <fieldset v-if="adapter" class="assistant-voice-conversation" :disabled="disabled">
30
- <AssistantConversationElement :adapter="adapter" :avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)">
31
- <template #avatar="size"><slot name="avatar" v-bind="avatarVisual" :size="size.size" :height="size.height"><VoiceAvatar v-bind="avatarVisual" /></slot></template>
32
- <template #composer-tools><div ref="toolsTarget" class="assistant-voice-conversation__tools" /></template>
33
- <template #composer-feedback>
34
- <VoiceConversationControls :session="session" :disabled="disabled" compact :tools-target="toolsTarget">
35
- <template #work-control><slot name="work-control" /></template>
36
- </VoiceConversationControls>
37
- </template>
38
- </AssistantConversationElement>
36
+ <AssistantConversationElement :adapter="presentationAdapter" :avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)">
37
+ <template v-if="showAvatar" #avatar="size">
38
+ <slot name="avatar" v-bind="avatarVisual" :size="size.size" :height="size.height">
39
+ <VoiceAvatar v-bind="avatarVisual" />
40
+ </slot>
41
+ </template>
42
+ <template v-if="showAvatar" #avatar-tools><div ref="toolsTarget" class="assistant-voice-conversation__tools" /></template>
43
+ <template #avatar-control="scope">
44
+ <slot name="avatar-control" v-bind="scope">
45
+ <span v-if="scope.size === 'hidden' && voice.listening.value && !microphoneMuted" role="img" aria-label="Listening" title="Listening">
46
+ <v-icon :icon="mdiMicrophone" size="20" aria-hidden="true" />
47
+ </span>
48
+ <span v-if="scope.size === 'hidden' && voice.speaking.value" role="img" aria-label="Speaking" title="Speaking">
49
+ <v-icon :icon="mdiVolumeHigh" size="20" aria-hidden="true" />
50
+ </span>
51
+ </slot>
52
+ </template>
53
+ <template #composer-feedback>
54
+ <VoiceConversationControls :session="session" :disabled="disabled" compact :icon-only="showAvatar" :tools-target="toolsTarget" :review-in-transcript="Boolean(presentationAdapter?.conversation?.previewMessage)">
55
+ <template #work-control><slot name="work-control" /></template>
56
+ <template #settings-control><slot name="settings-control" /></template>
57
+ </VoiceConversationControls>
58
+ </template>
59
+ </AssistantConversationElement>
39
60
  </fieldset>
40
61
  <fieldset v-else class="assistant-voice" :disabled="disabled">
41
- <section class="assistant-voice__call" :aria-label="`${targetLabel} voice chat controls`">
42
- <div class="assistant-voice__call-body">
62
+ <section
63
+ class="assistant-voice__call" :class="{ 'assistant-voice__call--without-avatar': !showAvatar }"
64
+ :aria-label="`${targetLabel} voice chat controls`"
65
+ >
66
+ <div v-if="showAvatar" class="assistant-voice__call-body">
43
67
  <div class="assistant-voice__call-portrait" :style="{ '--call-audio-scale': 1 + callAudioLevel * .16 }" aria-hidden="true">
44
68
  <slot name="avatar" v-bind="avatarVisual"><VoiceAvatar v-bind="avatarVisual" /></slot>
45
69
  </div>
@@ -54,7 +78,10 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
54
78
  <p class="assistant-voice__caption-text text-body-medium">{{ voiceAnswer }}</p>
55
79
  </section>
56
80
  </div>
57
- <VoiceConversationControls :session="session" :disabled="disabled"><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>
58
85
  </section>
59
86
  </fieldset>
60
87
  </template>
@@ -64,12 +91,11 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
64
91
  .assistant-voice-conversation__tools { display: flex; flex-wrap: wrap; gap: 4px; }
65
92
  .assistant-voice { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; border: 0; padding: 0; margin: 0; min-width: 0; }
66
93
  .assistant-voice__call { width: 100%; flex: 1; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) minmax(144px, 30%) auto; }
94
+ .assistant-voice__call--without-avatar { grid-template-rows: minmax(144px, 1fr) auto; }
67
95
  .assistant-voice__call-body { min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px 16px 0; text-align: center; }
68
96
  .assistant-voice__call-portrait { position: relative; height: min(240px, calc(100% - 64px)); aspect-ratio: 1; max-width: 100%; margin: 8px auto; isolation: isolate; }
69
97
  .assistant-voice__call-portrait::before { content: ''; position: absolute; inset: -12px; border-radius: 50%; background: rgba(var(--v-theme-primary), .12); transform: scale(var(--call-audio-scale)); transition: transform 80ms linear; z-index: -1; }
70
98
  .assistant-voice__call-portrait :deep(svg) { width: 100%; height: 100%; }
71
- .assistant-voice__call-status-row { display: flex; flex: 0 0 48px; justify-content: center; align-items: center; gap: 8px; height: 48px; }
72
- .assistant-voice__call-status-row p { margin: 0; max-height: 48px; overflow: hidden; }
73
99
  .assistant-voice__captions { min-height: 0; display: grid; grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 0 16px; }
74
100
  .assistant-voice__caption { min-height: 0; padding: 8px 12px; border-radius: 12px; background: rgba(var(--v-theme-on-surface), .04); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; touch-action: pan-y; }
75
101
  .assistant-voice__caption > strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -3,17 +3,31 @@ import { computed, useId } from "vue";
3
3
  import { mdiMicrophone, mdiMicrophoneOff, mdiStop, mdiVolumeHigh, mdiVolumeOff } from "@mdi/js";
4
4
  import { useVoiceLauncher } from "./voiceLauncher.js";
5
5
 
6
- const props = defineProps({ session: { type: Object, required: true }, disabled: { type: Boolean, default: false }, compact: { type: Boolean, default: false }, toolsTarget: { type: Object, default: null } });
6
+ const props = defineProps({
7
+ session: { type: Object, required: true },
8
+ disabled: { type: Boolean, default: false },
9
+ compact: { type: Boolean, default: false },
10
+ iconOnly: { type: Boolean, default: false },
11
+ reviewInTranscript: { type: Boolean, default: false },
12
+ toolsTarget: { type: Object, default: null },
13
+ feedbackTarget: { type: Object, default: null }
14
+ });
7
15
  const {
8
16
  targetLabel, voice, error, microphoneMuted, live, starting, callMode,
9
17
  callStatus, speechActive, heldTranscript,
10
18
  capturing, pendingTranscript, pushHolding, sending,
11
- stopSpeech, startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleHandsFree, readAloud, toggleReadAloud,
19
+ stopSpeech, startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleHandsFree, readAloud, toggleReadAloud, enableSound,
12
20
  deliverTranscript, discardHeldRecording, editTranscript
13
21
  } = props.session;
14
- const handsFreeListening = computed(() => live.value && callMode.value === "hands-free" && !microphoneMuted.value);
15
- const canTalk = computed(() => !props.disabled && !sending.value && !pendingTranscript.value &&
16
- (!capturing.value || voice.listening.value || pushHolding.value || starting.value));
22
+ const controlStatus = computed(() => {
23
+ if (props.iconOnly && voice.listening.value && !starting.value && !error.value && !voice.error.value) return "";
24
+ if (!props.compact || !props.session.hasUnsentSpeech.value) return callStatus.value;
25
+ if (pendingTranscript.value) return props.reviewInTranscript ? "" : "Send or discard speech to send text.";
26
+ if (live.value && callMode.value === "hands-free" && voice.listening.value && !microphoneMuted.value) return "Listening…";
27
+ return voice.listening.value ? "Recording · finish speech to send text." : "Finish speech to send text.";
28
+ });
29
+ const handsFreeListening = computed(() => live.value && callMode.value === "hands-free" && voice.listening.value && !microphoneMuted.value);
30
+ const canTalk = computed(() => !props.disabled && (!capturing.value || voice.listening.value || pushHolding.value || starting.value));
17
31
  const talkLabel = computed(() => starting.value ? "Connecting…" : pushHolding.value ? "Release to send" : handsFreeListening.value ? "Pause" : "Talk");
18
32
  const gestureHint = useId();
19
33
  const gesture = useVoiceLauncher({
@@ -26,49 +40,83 @@ const gesture = useVoiceLauncher({
26
40
 
27
41
  <template>
28
42
  <fieldset class="assistant-voice-controls" :class="{ 'assistant-voice-controls--compact': compact }" :disabled="disabled">
29
- <p v-if="error || voice.error.value" class="assistant-voice__error" role="alert">{{ error || voice.error.value }}</p>
30
- <p v-if="callStatus" class="assistant-voice-controls__status text-body-small" role="status">{{ callStatus }}</p>
31
- <p v-if="compact && heldTranscript && !pendingTranscript" class="assistant-voice-controls__provisional" role="status" aria-label="Recognized words">Not sent · {{ heldTranscript }}</p>
32
- <section v-if="pendingTranscript && (pendingTranscript.reviewBeforeSend || !sending)" class="assistant-voice__review" aria-label="Review voice message">
33
- <v-textarea :model-value="pendingTranscript.text" label="Review your message" :disabled="sending" :density="compact ? 'compact' : 'default'" :rows="compact ? 1 : 2" :max-rows="compact ? 2 : 3" auto-grow hide-details @update:model-value="editTranscript" />
34
- <div class="assistant-voice__review-actions">
35
- <v-btn :disabled="sending" min-height="48" @click="discardHeldRecording">Discard</v-btn>
36
- <v-btn :disabled="sending || !pendingTranscript.text.trim()" min-height="48" color="primary" @click="deliverTranscript">{{ sending ? 'Sending…' : 'Send' }}</v-btn>
37
- </div>
38
- </section>
43
+ <Teleport :to="feedbackTarget" :disabled="!feedbackTarget">
44
+ <fieldset class="assistant-voice-controls__feedback" :class="{ 'assistant-voice-controls__feedback--compact': compact }" :disabled="disabled">
45
+ <p
46
+ v-if="error || voice.error.value" class="assistant-voice__error"
47
+ :class="{ 'text-body-small': compact }" role="alert"
48
+ >
49
+ {{ error || voice.error.value }}
50
+ </p>
51
+ <p v-if="controlStatus" class="assistant-voice-controls__status text-body-small" role="status" :title="callStatus">{{ controlStatus }}</p>
52
+ <p v-if="compact && !reviewInTranscript && heldTranscript && !pendingTranscript" class="assistant-voice-controls__provisional" role="status" aria-label="Recognized words">Not sent · {{ heldTranscript }}</p>
53
+ <p v-if="readAloud && voice.playbackBlocked?.value" class="assistant-voice-controls__status" role="status">Sound is blocked. Enable sound to hear future replies.</p>
54
+ <v-btn v-if="readAloud && voice.playbackBlocked?.value" min-height="48" @click="enableSound">Enable sound</v-btn>
55
+ <section v-if="!reviewInTranscript && pendingTranscript && (pendingTranscript.reviewBeforeSend || !sending)" class="assistant-voice__review" aria-label="Review voice message">
56
+ <v-textarea
57
+ class="assistant-voice__review-input" :model-value="pendingTranscript.text" label="Review your message" :disabled="sending"
58
+ :density="compact ? 'compact' : 'default'" :rows="compact ? 1 : 2" :max-rows="compact ? 2 : 3"
59
+ auto-grow hide-details @update:model-value="editTranscript"
60
+ />
61
+ <div class="assistant-voice__review-actions">
62
+ <v-btn :disabled="sending" min-height="48" @click="discardHeldRecording">Discard</v-btn>
63
+ <v-btn :disabled="sending || !pendingTranscript.text.trim()" min-height="48" color="primary" @click="deliverTranscript">{{ sending ? 'Sending…' : 'Send' }}</v-btn>
64
+ </div>
65
+ </section>
66
+ </fieldset>
67
+ </Teleport>
39
68
  <Teleport :to="toolsTarget" :disabled="!toolsTarget">
40
- <fieldset class="assistant-voice-controls__tools" :class="{ 'assistant-voice-controls__tools--compact': compact }" :disabled="disabled" :aria-label="`${targetLabel} voice chat controls`">
69
+ <fieldset class="assistant-voice-controls__tools" :class="{ 'assistant-voice-controls__tools--compact': compact, 'assistant-voice-controls__tools--icons': iconOnly }" :disabled="disabled" :aria-label="`${targetLabel} voice chat controls`">
70
+ <div v-if="compact || iconOnly || speechActive" class="assistant-voice-controls__stop">
41
71
  <v-btn v-if="speechActive" :icon="mdiStop" variant="text" min-width="48" min-height="48" :aria-label="`Stop ${targetLabel} speaking`" title="Stop speaking" @click="stopSpeech" />
42
- <slot name="work-control" />
43
- <div class="assistant-voice__talk-action">
44
- <v-btn
45
- class="assistant-voice__talk" color="primary" :variant="handsFreeListening ? 'tonal' : 'flat'" :min-height="compact ? 48 : 64" rounded="xl"
46
- :prepend-icon="voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff" :aria-pressed="handsFreeListening || pushHolding" :aria-describedby="gestureHint"
47
- :disabled="!canTalk"
48
- @pointerdown="gesture.pointerDown" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel"
49
- @keydown.space.prevent="gesture.keyDown" @keyup.space.prevent="gesture.keyUp"
50
- @keydown.enter.prevent="gesture.keyDown" @keyup.enter.prevent="gesture.keyUp" @blur="gesture.cancel" @contextmenu.prevent @click="gesture.click"
51
- >
52
- {{ talkLabel }}
53
- </v-btn>
54
- <v-btn
55
- :icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" variant="tonal" :min-height="compact ? 48 : 64" :min-width="compact ? 48 : 64" rounded="xl"
56
- :aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
57
- :title="readAloud ? 'Spoken replies on' : 'Spoken replies off'" @click="toggleReadAloud"
58
- />
59
- <p :id="gestureHint" class="text-body-small text-medium-emphasis">{{ handsFreeListening ? 'Tap to pause · hold to speak' : 'Tap for hands-free · hold to speak' }}</p>
60
- </div>
72
+ </div>
73
+ <slot name="work-control" />
74
+ <div class="assistant-voice__talk-action">
75
+ <v-btn
76
+ class="assistant-voice__talk" color="primary" :variant="iconOnly ? 'text' : handsFreeListening ? 'tonal' : 'flat'" :min-height="iconOnly ? 44 : compact ? 48 : 64" rounded="xl"
77
+ :icon="iconOnly ? (voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff) : undefined"
78
+ :prepend-icon="iconOnly ? undefined : (voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff)"
79
+ :aria-label="iconOnly ? talkLabel : undefined" title="click or long press to talk"
80
+ :aria-pressed="handsFreeListening || pushHolding" :aria-describedby="gestureHint"
81
+ :disabled="!canTalk" :slim="compact || iconOnly"
82
+ :width="iconOnly ? 40 : compact ? 148 : undefined" :min-width="iconOnly ? 40 : compact ? 148 : undefined"
83
+ @pointerdown="gesture.pointerDown" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel"
84
+ @keydown.space.prevent="gesture.keyDown" @keyup.space.prevent="gesture.keyUp"
85
+ @keydown.enter.prevent="gesture.keyDown" @keyup.enter.prevent="gesture.keyUp" @blur="gesture.cancel" @contextmenu.prevent @click="gesture.click"
86
+ >
87
+ <span v-if="iconOnly" class="assistant-voice-controls__icon-disc"><v-icon size="20" :icon="voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff" /></span>
88
+ <template v-else>{{ talkLabel }}</template>
89
+ </v-btn>
90
+ <v-btn
91
+ :icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" :variant="iconOnly ? 'text' : 'tonal'" :color="iconOnly ? 'primary' : undefined" :min-height="iconOnly ? 44 : compact ? 48 : 64" :width="iconOnly ? 40 : undefined" :min-width="iconOnly ? 40 : compact ? 48 : 64" rounded="xl"
92
+ :aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
93
+ :title="readAloud ? 'Spoken replies on' : 'Spoken replies off'" @click="toggleReadAloud"
94
+ >
95
+ <template v-if="iconOnly" #default>
96
+ <span class="assistant-voice-controls__icon-disc"><v-icon size="20" :icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" /></span>
97
+ </template>
98
+ </v-btn>
99
+ <slot name="settings-control" />
100
+ <p :id="gestureHint" class="text-body-small text-medium-emphasis">{{ handsFreeListening ? 'Tap to pause · hold to speak' : 'Tap for hands-free · hold to speak' }}</p>
101
+ </div>
61
102
  </fieldset>
62
103
  </Teleport>
63
104
  </fieldset>
64
105
  </template>
65
106
 
66
107
  <style scoped>
67
- .assistant-voice-controls, .assistant-voice-controls__tools { border: 0; padding: 0; margin: 0; min-width: 0; }
108
+ .assistant-voice-controls, .assistant-voice-controls__feedback, .assistant-voice-controls__tools { border: 0; padding: 0; margin: 0; min-width: 0; }
68
109
  .assistant-voice-controls { flex: 0 0 auto; }
69
110
  .assistant-voice-controls--compact { flex: 1 1 100%; width: 100%; }
70
111
  .assistant-voice-controls__tools { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; }
71
112
  .assistant-voice-controls__status { text-align: center; margin: 0; }
113
+ .assistant-voice-controls__feedback--compact > .assistant-voice-controls__status { min-height: 1lh; }
114
+ .assistant-voice-controls__stop { display: contents; }
115
+ .assistant-voice-controls__tools--compact .assistant-voice-controls__stop {
116
+ display: block;
117
+ flex: 0 0 48px;
118
+ height: 48px;
119
+ }
72
120
  .assistant-voice-controls__provisional { margin: 0; max-height: 3rem; overflow-y: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
73
121
  .assistant-voice__talk-action { width: min(288px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 6px 8px; padding: 16px; }
74
122
  .assistant-voice__talk { touch-action: none; user-select: none; -webkit-touch-callout: none; }
@@ -76,10 +124,24 @@ const gesture = useVoiceLauncher({
76
124
  .assistant-voice__review { flex: 0 0 auto; max-height: 160px; overflow-y: auto; padding: 8px 16px; }
77
125
  .assistant-voice__review-actions { display: flex; justify-content: flex-end; gap: 8px; }
78
126
  .assistant-voice__error { padding: 8px 16px; color: rgb(var(--v-theme-error)); }
79
- .assistant-voice-controls__tools--compact { justify-content: flex-start; gap: 4px; }
80
- .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; }
81
129
  .assistant-voice-controls__tools--compact .assistant-voice__talk-action p { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
82
- .assistant-voice-controls--compact .assistant-voice__review { max-height: 128px; padding: 0; }
83
- .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; }
84
146
  @media (max-height: 650px) { .assistant-voice__talk-action { padding-top: 8px; } }
85
147
  </style>
@@ -1,9 +1,14 @@
1
1
  <script setup>
2
- import { 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,29 +25,16 @@ async function openText() {
20
25
 
21
26
  <template>
22
27
  <ConversationDialog
23
- v-if="state.session" :model-value="state.visible" :activator="activator" :title="state.binding.label || 'Assistant'" mode="talk" :show-modes="Boolean(state.binding.openText)"
24
- minimizable close-label="Close voice chat" @minimize="controller.minimize()" @update:model-value="invoke(() => controller.end({ discard: true }))"
28
+ v-if="state.session" :model-value="state.visible" :activator="activator" :presentation="presentation"
29
+ :title="state.binding.label || 'Assistant'" mode="talk"
30
+ :show-modes="Boolean(state.binding.openText && !state.binding.adapter)"
31
+ :minimizable="presentation === 'dialog'" close-label="Close voice chat" @minimize="controller.minimize()" @update:model-value="invoke(() => controller.end({ discard: true }))"
25
32
  @update:mode="value => value === 'text' && invoke(openText)"
26
33
  >
27
- <template #header-actions>
28
- <v-menu :close-on-content-click="false" location="bottom end" @update:model-value="open => open && !$slots.settings && invoke(() => state.session.voice.connect())">
29
- <template #activator="{ props: settingsButton }">
30
- <v-btn v-bind="settingsButton" :icon="mdiCog" variant="text" aria-label="Voice settings" title="Voice settings" />
31
- </template>
32
- <v-card class="voice-host__settings" role="region" aria-label="Voice settings">
33
- <v-card-text>
34
- <slot name="settings" :voice="state.session.voice">
35
- <v-select
36
- v-model="state.session.voice.selectedVoice.value"
37
- :items="state.session.voice.availableVoices.value" item-title="label" item-value="id"
38
- :disabled="state.session.voice.availableVoices.value.length < 2"
39
- label="Speaking voice" density="comfortable"
40
- hint="Applies to the next spoken reply" persistent-hint
41
- />
42
- </slot>
43
- </v-card-text>
44
- </v-card>
45
- </v-menu>
34
+ <template v-if="!state.binding.adapter" #header-actions>
35
+ <VoiceConversationSettings :voice="state.session.voice" @error="state.error = $event.message">
36
+ <template v-if="$slots.settings" #default="settings"><slot name="settings" v-bind="settings" /></template>
37
+ </VoiceConversationSettings>
46
38
  </template>
47
39
  <v-alert v-if="state.error" type="error" density="compact" role="alert">{{ state.error }}</v-alert>
48
40
  <v-alert v-if="state.nextTarget" type="info" class="voice-host__switch">
@@ -55,16 +47,26 @@ async function openText() {
55
47
  </v-alert>
56
48
  <div class="voice-host__body">
57
49
  <slot name="conversation" :binding="state.binding" :session="state.session" :disabled="state.busy">
58
- <VoiceConversation :key="state.binding.id" :session="state.session" :disabled="state.busy" :adapter="state.binding.adapter || null" :avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)">
59
- <template v-if="$slots.avatar" #avatar="visual"><slot name="avatar" v-bind="visual" :binding="state.binding" /></template>
60
- <template #work-control>
61
- <v-btn v-if="state.binding.cancelWork && state.binding.state.status === 'working'" :disabled="state.busy" :icon="mdiStop" variant="text" aria-label="Stop agent work" title="Stop agent work" @click="invoke(state.binding.cancelWork)" />
62
- </template>
63
- </VoiceConversation>
50
+ <VoiceConversation
51
+ :key="state.binding.id" :session="state.session" :disabled="state.busy"
52
+ :adapter="state.binding.adapter || null" :show-avatar="state.binding.showAvatar !== false"
53
+ :avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)"
54
+ >
55
+ <template v-if="$slots.avatar" #avatar="visual"><slot name="avatar" v-bind="visual" :binding="state.binding" /></template>
56
+ <template v-if="$slots['avatar-control']" #avatar-control="scope"><slot name="avatar-control" v-bind="scope" :binding="state.binding" :session="state.session" /></template>
57
+ <template v-if="presentation === 'inline' || state.binding.adapter" #settings-control>
58
+ <VoiceConversationSettings :voice="state.session.voice" compact @error="state.error = $event.message">
59
+ <template v-if="$slots.settings" #default="settings"><slot name="settings" v-bind="settings" /></template>
60
+ </VoiceConversationSettings>
61
+ </template>
62
+ <template #work-control>
63
+ <v-btn v-if="state.binding.cancelWork && state.binding.state.status === 'working'" :disabled="state.busy" :icon="mdiStop" variant="text" aria-label="Stop agent work" title="Stop agent work" @click="invoke(state.binding.cancelWork)" />
64
+ </template>
65
+ </VoiceConversation>
64
66
  </slot>
65
67
  </div>
66
68
  </ConversationDialog>
67
- <template v-if="state.session && !state.visible">
69
+ <template v-if="presentation === 'dialog' && state.session && !state.visible">
68
70
  <slot v-if="$slots.reopen" name="reopen" :controller="controller" />
69
71
  <v-btn v-else class="voice-host__reopen" color="primary" :prepend-icon="mdiHeadset" min-height="56" @click="controller.reveal()">
70
72
  {{ state.binding.label || 'Assistant' }} · Voice chat
@@ -76,6 +78,5 @@ async function openText() {
76
78
  .voice-host__body { display: flex; flex: 1; min-height: 0; overflow: hidden; }
77
79
  .voice-host__switch-actions { display: flex; flex-wrap: wrap; gap: 4px; }
78
80
  .voice-host__switch { flex: 0 0 auto; }
79
- .voice-host__settings { width: 320px; max-width: calc(100vw - 32px); }
80
81
  .voice-host__reopen { position: fixed; right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); max-width: calc(100vw - 24px); z-index: 1900; }
81
82
  </style>