feat(stage): group chat controls and track speech mute (#2139)

This commit is contained in:
RainbowBird
2026-07-28 19:34:14 +08:00
committed by GitHub
parent 899131b0a1
commit ae35b580d8
16 changed files with 299 additions and 107 deletions
@@ -199,15 +199,36 @@ onMounted(() => {
<div translate-y="[-100%]" absolute right-0 px-3 pb-3 font-sans>
<div flex="~ col" gap-1>
<ActionAbout />
<button
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md
title="Conversations"
@click="sessionsDrawerOpen = true"
>
<div i-solar:chat-line-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
</button>
<div flex="~ col" items-end gap-1>
<button
data-testid="conversation-selector-button"
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md
:title="t('stage.chat.sessions.title')"
:aria-label="t('stage.chat.sessions.title')"
@click="sessionsDrawerOpen = true"
>
<div i-solar:chat-line-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
</button>
<button
data-testid="speech-mute-button"
:class="[
'w-fit flex items-center self-end justify-center rounded-xl border-2 border-solid p-2 backdrop-blur-md',
'border-neutral-100/60 text-neutral-500 transition-colors active:scale-95 dark:border-neutral-800/30 dark:text-neutral-400',
speechMuted
? 'bg-primary-100/80 text-primary-600 dark:bg-primary-900/60 dark:text-primary-300'
: 'bg-neutral-50/70 hover:text-primary-500 dark:bg-neutral-800/70 dark:hover:text-primary-400',
]"
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-pressed="speechMuted"
@click="toggleSpeechMuted"
>
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone size-5" />
<div v-else class="i-solar:volume-loud-bold-duotone size-5" />
</button>
</div>
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
<HearingConfigDialog
v-model:enabled="enabled"
@@ -227,23 +248,6 @@ onMounted(() => {
</Transition>
</button>
</HearingConfigDialog>
<button
data-testid="speech-mute-button"
:class="[
'w-fit flex items-center self-end justify-center rounded-xl border-2 border-solid p-2 backdrop-blur-md',
'border-neutral-100/60 text-neutral-500 transition-colors active:scale-95 dark:border-neutral-800/30 dark:text-neutral-400',
speechMuted
? 'bg-primary-100/80 text-primary-600 dark:bg-primary-900/60 dark:text-primary-300'
: 'bg-neutral-50/70 hover:text-primary-500 dark:bg-neutral-800/70 dark:hover:text-primary-400',
]"
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-pressed="speechMuted"
@click="toggleSpeechMuted"
>
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone size-5" />
<div v-else class="i-solar:volume-loud-bold-duotone size-5" />
</button>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Theme" @click="toggleDark()">
<Transition name="fade" mode="out-in">
<div v-if="isDark" i-solar:moon-outline size-5 text="neutral-500 dark:neutral-400" />
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components/scenarios/chat'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
@@ -20,6 +21,7 @@ const { speechMuted, toggleSpeechMuted } = useStopSpeakingButton()
const { t } = useI18n()
const backgroundDialogOpen = ref(false)
const sessionsDrawerOpen = ref(false)
function handleCleanupMessages() {
const messageCount = messages.value.filter(message => message.role !== 'system').length
@@ -33,24 +35,40 @@ function handleCleanupMessages() {
<template>
<BackgroundDialogPicker v-model="backgroundDialogOpen" />
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
<div absolute bottom--8 right-0 flex gap-2>
<button
data-testid="speech-mute-button"
:class="[
'max-h-[10lh] min-h-[1lh] flex items-center justify-center rounded-md p-2 outline-none',
'text-lg transition-colors transition-transform active:scale-95',
speechMuted
? 'bg-primary-100 text-primary-600 dark:bg-primary-900/40 dark:text-primary-300'
: 'bg-neutral-100 text-neutral-500 hover:text-primary-500 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:text-primary-400',
]"
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-pressed="speechMuted"
@click="toggleSpeechMuted"
>
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone" />
<div v-else class="i-solar:volume-loud-bold-duotone" />
</button>
<div flex gap-1>
<button
data-testid="conversation-selector-button"
:class="[
'max-h-[10lh] min-h-[1lh] flex items-center justify-center rounded-md p-2 outline-none',
'bg-neutral-100 text-lg text-neutral-500 transition-colors transition-transform active:scale-95',
'hover:text-primary-500 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:text-primary-400',
]"
:title="t('stage.chat.sessions.title')"
:aria-label="t('stage.chat.sessions.title')"
@click="sessionsDrawerOpen = true"
>
<div class="i-solar:chat-line-bold-duotone" />
</button>
<button
data-testid="speech-mute-button"
:class="[
'max-h-[10lh] min-h-[1lh] flex items-center justify-center rounded-md p-2 outline-none',
'text-lg transition-colors transition-transform active:scale-95',
speechMuted
? 'bg-primary-100 text-primary-600 dark:bg-primary-900/40 dark:text-primary-300'
: 'bg-neutral-100 text-neutral-500 hover:text-primary-500 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:text-primary-400',
]"
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-pressed="speechMuted"
@click="toggleSpeechMuted"
>
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone" />
<div v-else class="i-solar:volume-loud-bold-duotone" />
</button>
</div>
<ViewControls />
<button
class="max-h-[10lh] min-h-[1lh]"
@@ -3,7 +3,6 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
import { errorMessageFrom } from '@moeru/std'
import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components/scenarios/chat'
import { HearingConfig } from '@proj-airi/stage-ui/components/scenarios/dialogs/audio-input/index'
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
@@ -26,7 +25,6 @@ import { useStopSpeakingButton } from '../../composables/useStopSpeakingButton'
const messageInput = ref<string>('')
const hearingPopoverOpen = ref(false)
const sessionsDrawerOpen = ref(false)
const isComposing = ref(false)
const DOUBLE_ENTER_INTERVAL_MS = 300
const TRAILING_NEWLINES_REGEX = /[\r\n]+$/
@@ -205,24 +203,10 @@ watch(sendMode, () => {
@compositionend="isComposing = false"
/>
<!-- Bottom-left action button: Microphone -->
<!-- Input configuration controls -->
<div
absolute bottom-2 left-2 z-10 flex items-center gap-2
>
<!-- Conversations drawer trigger -->
<button
:class="[
'h-8 w-8 flex items-center justify-center rounded-md outline-none transition-all duration-200 active:scale-95',
'text-lg text-neutral-500 dark:text-neutral-400',
]"
title="Conversations"
@click="sessionsDrawerOpen = true"
>
<div class="i-solar:chat-line-bold-duotone h-5 w-5" />
</button>
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
@@ -6,7 +6,8 @@ import { useStopSpeakingButton } from './useStopSpeakingButton'
const nowSpeaking = ref(false)
const speechMuted = ref(false)
const requestStopSpeakingMock = vi.fn()
const toggleSpeechMutedMock = vi.fn()
const setSpeechMutedMock = vi.fn()
const trackSpeechMuteToggledMock = vi.fn()
const trackTtsStopClickedMock = vi.fn()
vi.mock('@proj-airi/stage-ui/stores/audio', () => ({
@@ -18,13 +19,14 @@ vi.mock('@proj-airi/stage-ui/stores/audio', () => ({
vi.mock('@proj-airi/stage-ui/stores/speech-output-control', () => ({
useSpeechOutputControlStore: () => ({
requestStopSpeaking: requestStopSpeakingMock,
setSpeechMuted: setSpeechMutedMock,
speechMuted,
toggleSpeechMuted: toggleSpeechMutedMock,
}),
}))
vi.mock('@proj-airi/stage-ui/composables/use-analytics', () => ({
useAnalytics: () => ({
trackSpeechMuteToggled: trackSpeechMuteToggledMock,
trackTtsStopClicked: trackTtsStopClickedMock,
}),
}))
@@ -74,16 +76,72 @@ describe('useStopSpeakingButton', () => {
})
})
it('exposes persisted mute state and toggles it through the shared output store', () => {
speechMuted.value = true
toggleSpeechMutedMock.mockClear()
it('tracks mute and unmute with the active playback state', async () => {
speechMuted.value = false
nowSpeaking.value = true
setSpeechMutedMock.mockClear()
trackSpeechMuteToggledMock.mockClear()
const controls = useStopSpeakingButton()
expect(controls.speechMuted.value).toBe(true)
await controls.toggleSpeechMuted()
controls.toggleSpeechMuted()
expect(setSpeechMutedMock).toHaveBeenCalledWith(true)
expect(trackSpeechMuteToggledMock).toHaveBeenCalledWith({
muted: true,
was_speaking: true,
})
expect(toggleSpeechMutedMock).toHaveBeenCalledOnce()
speechMuted.value = true
nowSpeaking.value = false
await controls.toggleSpeechMuted()
expect(setSpeechMutedMock).toHaveBeenLastCalledWith(false)
expect(trackSpeechMuteToggledMock).toHaveBeenLastCalledWith({
muted: false,
was_speaking: false,
})
})
// ROOT CAUSE:
//
// Electron's auxiliary /chat renderer has its own Pinia instance, so its
// local nowSpeaking value stays false while the main Stage renderer speaks.
//
// The title-bar mute control now resolves state from the output host before
// capturing speech_mute_toggled.
it('tracks the speaking state resolved from a remote output host', async () => {
speechMuted.value = false
nowSpeaking.value = false
setSpeechMutedMock.mockClear()
trackSpeechMuteToggledMock.mockClear()
const resolveSpeakingState = vi.fn().mockResolvedValue(true)
const controls = useStopSpeakingButton({ resolveSpeakingState })
await controls.toggleSpeechMuted()
expect(resolveSpeakingState).toHaveBeenCalledTimes(1)
expect(setSpeechMutedMock).toHaveBeenCalledWith(true)
expect(trackSpeechMuteToggledMock).toHaveBeenCalledWith({
muted: true,
was_speaking: true,
})
})
it('still toggles mute without capturing a false metric when the output host is unavailable', async () => {
speechMuted.value = false
setSpeechMutedMock.mockClear()
trackSpeechMuteToggledMock.mockClear()
const controls = useStopSpeakingButton({
resolveSpeakingState: () => Promise.reject(new Error('output host reloading')),
})
await controls.toggleSpeechMuted()
expect(setSpeechMutedMock).toHaveBeenCalledWith(true)
expect(trackSpeechMuteToggledMock).not.toHaveBeenCalled()
})
})
@@ -10,11 +10,18 @@ import { computed } from 'vue'
* Manual stops affect current playback without cancelling text generation.
* Mute is persisted by the shared store and also blocks future TTS sessions.
*/
export function useStopSpeakingButton() {
export function useStopSpeakingButton(options: {
/**
* Reads speaking state from the renderer that owns playback.
*
* @default The current renderer's speaking store.
*/
resolveSpeakingState?: () => boolean | Promise<boolean>
} = {}) {
const { nowSpeaking } = storeToRefs(useSpeakingStore())
const speechOutputControlStore = useSpeechOutputControlStore()
const { speechMuted } = storeToRefs(speechOutputControlStore)
const { trackTtsStopClicked } = useAnalytics()
const { trackSpeechMuteToggled, trackTtsStopClicked } = useAnalytics()
const showStopSpeakingButton = computed(() => nowSpeaking.value)
@@ -28,11 +35,32 @@ export function useStopSpeakingButton() {
speechOutputControlStore.requestStopSpeaking('manual-all')
}
async function toggleSpeechMuted() {
let wasSpeaking: boolean
try {
wasSpeaking = await (options.resolveSpeakingState?.() ?? nowSpeaking.value)
}
catch {
// Muting is the user action; analytics must not make it fail when an
// auxiliary renderer cannot reach the output host during a reload.
speechOutputControlStore.setSpeechMuted(!speechMuted.value)
return
}
const muted = !speechMuted.value
speechOutputControlStore.setSpeechMuted(muted)
trackSpeechMuteToggled({
muted,
was_speaking: wasSpeaking,
})
}
return {
showStopSpeakingButton,
speechMuted,
stopSpeakingFromChat,
stopAllSpeaking,
toggleSpeechMuted: speechOutputControlStore.toggleSpeechMuted,
toggleSpeechMuted,
}
}