fix(stage-tamagotchi): sync hearing text with chat input (#2264)

This commit is contained in:
Neko
2026-08-12 05:00:57 +08:00
committed by GitHub
parent 01eac03af1
commit ac0192e5b6
6 changed files with 197 additions and 2 deletions
@@ -21,10 +21,12 @@ import { useRouter } from 'vue-router'
import JournalToolCallBlock from './chat-tool-renderers/journal-tool-call-block.vue'
import { useHearingInputChannel } from '../composables/use-hearing-input-channel'
import { artistryToolReferences, widgetToolReferences } from '../stores/tools'
const router = useRouter()
const messageInput = ref('')
useHearingInputChannel(messageInput)
const lastEnterTime = ref(0)
const attachments = ref<{ type: 'image', data: string, mimeType: string, url: string }[]>([])
@@ -0,0 +1,82 @@
import type { HearingInputChannelEvent } from '@proj-airi/stage-shared'
import { hearingInputChannelName } from '@proj-airi/stage-shared'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref, shallowRef } from 'vue'
import { useHearingInputChannel } from './use-hearing-input-channel'
const broadcastChannelMock = vi.hoisted(() => ({
useBroadcastChannel: vi.fn(),
}))
vi.mock('@vueuse/core', () => ({
useBroadcastChannel: broadcastChannelMock.useBroadcastChannel,
}))
describe('useHearingInputChannel', () => {
let data: ReturnType<typeof shallowRef<HearingInputChannelEvent | undefined>>
beforeEach(() => {
data = shallowRef<HearingInputChannelEvent>()
broadcastChannelMock.useBroadcastChannel.mockReset()
broadcastChannelMock.useBroadcastChannel.mockReturnValue({ data })
})
it('listens on the shared Hearing input channel', () => {
useHearingInputChannel(ref(''))
expect(broadcastChannelMock.useBroadcastChannel).toHaveBeenCalledWith({
name: hearingInputChannelName,
})
})
it('replaces Provider revisions and clears only the owned suffix', async () => {
const input = ref('manual note')
useHearingInputChannel(input)
data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'hello' }
await nextTick()
expect(input.value).toBe('manual note hello')
data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'hello world' }
await nextTick()
expect(input.value).toBe('manual note hello world')
data.value = { operation: 'clear', sourceId: 'utterance-1' }
await nextTick()
expect(input.value).toBe('manual note')
})
it('ignores stale cleanup after a new Provider utterance starts', async () => {
const input = ref('')
useHearingInputChannel(input)
data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'first' }
await nextTick()
data.value = { operation: 'replace', sourceId: 'utterance-2', text: 'second' }
await nextTick()
expect(input.value).toBe('second')
data.value = { operation: 'clear', sourceId: 'utterance-1' }
await nextTick()
expect(input.value).toBe('second')
data.value = { operation: 'clear', sourceId: 'utterance-2' }
await nextTick()
expect(input.value).toBe('')
})
it('does not replace text after the user edits the Provider-owned suffix', async () => {
const input = ref('')
useHearingInputChannel(input)
data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'draft' }
await nextTick()
input.value = 'user edit'
data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'provider revision' }
await nextTick()
expect(input.value).toBe('user edit')
})
})
@@ -0,0 +1,39 @@
import type { HearingInputChannelEvent } from '@proj-airi/stage-shared'
import type { Ref } from 'vue'
import { hearingInputChannelName } from '@proj-airi/stage-shared'
import { useStreamingTranscriptionInput } from '@proj-airi/stage-ui/composables/use-streaming-transcription-input'
import { useBroadcastChannel } from '@vueuse/core'
import { watch } from 'vue'
/** Applies cross-window Hearing updates to one editable chat input. */
export function useHearingInputChannel(input: Ref<string>) {
const streamingInput = useStreamingTranscriptionInput(input)
const { data } = useBroadcastChannel<HearingInputChannelEvent, HearingInputChannelEvent>({
name: hearingInputChannelName,
})
let activeSourceId: string | undefined
watch(data, (event) => {
if (!event)
return
if (event.operation === 'replace') {
if (!event.text.trim())
return
if (activeSourceId && activeSourceId !== event.sourceId)
streamingInput.clear()
activeSourceId = event.sourceId
streamingInput.replace(event.text)
return
}
if (event.sourceId !== activeSourceId)
return
streamingInput.clear()
activeSourceId = undefined
})
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { CaptionChannelEvent } from '@proj-airi/stage-shared'
import type { CaptionChannelEvent, HearingInputChannelEvent } from '@proj-airi/stage-shared'
import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
import type { ModelSettingsRuntimeChannelEvent } from '../../shared/model-settings-runtime'
@@ -14,7 +14,7 @@ import {
useElectronRelativeMouse,
} from '@proj-airi/electron-vueuse'
import { createTranscriptBuffer } from '@proj-airi/pipelines-audio'
import { IS_DEV } from '@proj-airi/stage-shared'
import { hearingInputChannelName, IS_DEV } from '@proj-airi/stage-shared'
import { useModelStore, useThreeSceneIsTransparentAtPoint } from '@proj-airi/stage-ui-three'
import { HoloCoupon } from '@proj-airi/stage-ui/components'
import {
@@ -359,6 +359,46 @@ const voiceInputInteractionLifecycle = createVoiceInputInteractionLifecycle<Stop
// Caption overlay broadcast channel
const { post: postCaption } = useBroadcastChannel<CaptionChannelEvent, CaptionChannelEvent>({ name: 'airi-caption-overlay' })
const { post: postHearingInput } = useBroadcastChannel<HearingInputChannelEvent, HearingInputChannelEvent>({ name: hearingInputChannelName })
const hearingInputClearTimers = new Map<ReturnType<typeof setTimeout>, string>()
let hearingInputSequence = 0
let activeHearingInputSourceId: string | undefined
function currentHearingInputSourceId() {
activeHearingInputSourceId ??= `stage-tamagotchi:${++hearingInputSequence}`
return activeHearingInputSourceId
}
function postHearingInputEvent(event: HearingInputChannelEvent) {
const { error } = tryCatch(() => postHearingInput(event))
if (error)
console.warn('[Main Page] Failed to post Hearing input text:', error)
}
function replaceHearingInput(text: string) {
postHearingInputEvent({
operation: 'replace',
sourceId: currentHearingInputSourceId(),
text,
})
}
function clearHearingInput(sourceId = activeHearingInputSourceId) {
if (!sourceId)
return
postHearingInputEvent({ operation: 'clear', sourceId })
if (sourceId === activeHearingInputSourceId)
activeHearingInputSourceId = undefined
}
function scheduleHearingInputClear(sourceId: string) {
const timer = setTimeout(() => {
hearingInputClearTimers.delete(timer)
clearHearingInput(sourceId)
}, 250)
hearingInputClearTimers.set(timer, sourceId)
}
/**
* Reports a voice input pipeline failure to both the console and visible app UI.
@@ -527,6 +567,10 @@ function handleStreamingSentenceEnd(delta: string) {
if (!finalText || !finalText.trim())
return
const sourceId = currentHearingInputSourceId()
replaceHearingInput(finalText)
scheduleHearingInputClear(sourceId)
activeHearingInputSourceId = undefined
postSpeakerCaption(finalText, 'replace')
void sendVoiceInputTextToChat(finalText)
}
@@ -536,6 +580,7 @@ function handleStreamingTranscriptionUpdate(text: string) {
if (isVoiceInputSuppressed())
return
replaceHearingInput(text)
postSpeakerCaption(text, 'replace')
}
@@ -627,6 +672,7 @@ async function stopAudioInteractionConsumers(options: StopAudioInteractionOption
const flushTranscript = options.flushTranscript ?? true
clearAssistantSpeechResumeTimer()
clearHearingInput()
voiceInputGeneration += 1
removeStreamingTranscriptionConsumer(transcriptionConsumerId)
@@ -696,6 +742,12 @@ onMounted(() => {
})
onUnmounted(() => {
for (const [timer, sourceId] of hearingInputClearTimers) {
clearTimeout(timer)
clearHearingInput(sourceId)
}
hearingInputClearTimers.clear()
clearHearingInput()
postModelSettingsRuntimeEvent({
type: 'owner-gone',
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,