fix(desktop): don't replay earlier turns in voice conversation
When lastSpokenId is missing or stale, collectUnspokenTurnSpeech used index -1 and concatenated every prior assistant reply into one speech string. Bound collection to the current turn (after the last user message) and carry the spoken anchor from a null session id onto the created session so live conversations stay turn-scoped.
This commit is contained in:
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
|
||||
import { adoptSpokenReplySession, markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
|
||||
import { CONVERSATION_LEASE, READ_ALOUD_LEASE, syncTtsLease } from '@/lib/tts-lease'
|
||||
import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator'
|
||||
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
|
||||
@@ -65,8 +65,15 @@ export function useComposerVoice({
|
||||
const { $messages } = useComposerScope()
|
||||
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
||||
const ownsWakeIndicatorRef = useRef(false)
|
||||
const previousSessionIdRef = useRef(sessionId)
|
||||
const voiceStartRequest = useStore($voiceConversationStartRequest)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- session-id adopt token, not an atom mirror
|
||||
useEffect(() => {
|
||||
adoptSpokenReplySession(previousSessionIdRef.current, sessionId)
|
||||
previousSessionIdRef.current = sessionId
|
||||
}, [sessionId])
|
||||
|
||||
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
|
||||
focusInput,
|
||||
maxRecordingSeconds,
|
||||
|
||||
@@ -1342,6 +1342,19 @@ describe('collectUnspokenTurnSpeech', () => {
|
||||
expect(collectUnspokenTurnSpeech([assistant('a1', 'Done.')], 'a1')).toBeNull()
|
||||
expect(collectUnspokenTurnSpeech([user('u1', 'hello'), assistant('a1', '')], null)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not replay earlier turns when the spoken id is missing or stale', () => {
|
||||
const messages = [
|
||||
user('u1', 'old question'),
|
||||
assistant('a1', 'Previous output from last turn.'),
|
||||
user('u2', 'new question'),
|
||||
assistant('a2', 'Live reply only.')
|
||||
]
|
||||
|
||||
expect(collectUnspokenTurnSpeech(messages, null)?.text).toBe('Live reply only.')
|
||||
expect(collectUnspokenTurnSpeech(messages, 'vanished-stream-id')?.text).toBe('Live reply only.')
|
||||
expect(collectUnspokenTurnSpeech(messages, 'a1')?.text).toBe('Live reply only.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripPendingClarifyProjectionForCache', () => {
|
||||
|
||||
@@ -68,12 +68,26 @@ export interface UnspokenTurnSpeech {
|
||||
* selecting only one bubble silently drops everything after it. The blank-line
|
||||
* join is a sentence boundary for the server's cutter, so a sealed bubble's
|
||||
* tail is flushed as soon as the next bubble starts.
|
||||
*
|
||||
* If `lastSpokenId` is missing or stale (session id assigned mid-turn,
|
||||
* live-tail rewrite missed), do **not** fall back to index -1 — that replays
|
||||
* every earlier assistant turn as one speech string. Bound to the current
|
||||
* turn (assistant bubbles after the last user message) instead. A slice with
|
||||
* no user row (mid-turn interims only) still collects those assistants.
|
||||
*/
|
||||
export function collectUnspokenTurnSpeech(
|
||||
messages: ChatMessage[],
|
||||
lastSpokenId: string | null
|
||||
): UnspokenTurnSpeech | null {
|
||||
const spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
|
||||
let spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
|
||||
|
||||
if (spokenIndex < 0) {
|
||||
const lastUser = messages.findLastIndex(m => m.role === 'user' && !m.hidden)
|
||||
|
||||
if (lastUser >= 0) {
|
||||
spokenIndex = lastUser
|
||||
}
|
||||
}
|
||||
|
||||
let id: string | null = null
|
||||
let pending = false
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
absorbSpokenReplyRewrite,
|
||||
adoptSpokenReplySession,
|
||||
assistantReplyOrdinal,
|
||||
clearSpokenRepliesForTests,
|
||||
isLiveTailReplyId,
|
||||
@@ -90,3 +91,20 @@ describe('resolveSpokenReply', () => {
|
||||
expect(spoken?.ordinal).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('adoptSpokenReplySession', () => {
|
||||
it('moves the null-session anchor onto the created session id', () => {
|
||||
markAssistantIdSpoken(null, [assistant('a1')], 'a1')
|
||||
adoptSpokenReplySession(null, 'session-created')
|
||||
|
||||
expect(spokenReplyOf('session-created')?.id).toBe('a1')
|
||||
})
|
||||
|
||||
it('does not leak a spoken anchor from one real session into another', () => {
|
||||
markAssistantIdSpoken('session-a', [assistant('a1')], 'a1')
|
||||
adoptSpokenReplySession('session-a', 'session-b')
|
||||
|
||||
expect(spokenReplyOf('session-b')).toBeNull()
|
||||
expect(spokenReplyOf('session-a')?.id).toBe('a1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,6 +111,31 @@ export function markAssistantIdSpoken(
|
||||
markSpokenReply(sessionId, { id, ordinal })
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry the spoken anchor when a chat gets a real session id (null → created)
|
||||
* mid voice-conversation. Do not copy across two real sessions — that would
|
||||
* leak "already spoken" into a different transcript.
|
||||
*/
|
||||
export function adoptSpokenReplySession(
|
||||
fromSessionId: string | null | undefined,
|
||||
toSessionId: string | null | undefined
|
||||
): void {
|
||||
const fromKey = sessionKey(fromSessionId)
|
||||
const toKey = sessionKey(toSessionId)
|
||||
|
||||
if (fromKey === toKey || fromKey !== NO_SESSION) {
|
||||
return
|
||||
}
|
||||
|
||||
const from = lastSpokenBySession.get(fromKey)
|
||||
|
||||
if (!from || lastSpokenBySession.has(toKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
lastSpokenBySession.set(toKey, from)
|
||||
}
|
||||
|
||||
/** Current spoken anchor, migrated in place when the live row was rewritten. */
|
||||
export function resolveSpokenReply(
|
||||
sessionId: string | null | undefined,
|
||||
|
||||
Reference in New Issue
Block a user