diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index e66984f..d1417e3 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -62,6 +62,7 @@ import { useSession } from '@/features/auth/hooks'; import { useProfile } from '@/features/profile/hooks'; import { assistantSegmentMessageId, + playbackListKeyMatchesBubble, splitMessageParts, splitStreamingSegments, } from '@/features/conversation/message-split'; @@ -171,16 +172,6 @@ function segmentTtsUrlAt( /** 流式助手区与自动 TTS 的 `PlaybackItem.messageRef.listKey` 对齐,用于点区域停止朗读 */ const TTS_STREAMING_LIST_KEY = '__tts_streaming__'; -/** PlaybackItem.messageRef.listKey 可与 `item.id` 或 `${id}_seg_/part_` 后缀对齐 */ -function playbackMessageRefMatchesMessage( - playbackListKey: string | undefined, - messageItemId: string, -): boolean { - if (!playbackListKey?.length) return false; - if (playbackListKey === messageItemId) return true; - return playbackListKey.startsWith(`${messageItemId}_`); -} - /** 展平消息列表:assistant 消息按 [SPLIT] 边界拆成多条,每条一个 listKey */ function flattenMessagesForList( messages: MessageItem[], @@ -230,7 +221,9 @@ function MessageBubble({ onPauseAssistantTts, onResumeAssistantTts, onInterruptAssistantTts, + onPreemptAssistantPlayback, onReplayAssistantTts, + assistantPlaybackEngaged, bubbleTextStyle, voiceDurationTextStyle, readAloudIconSize, @@ -254,7 +247,11 @@ function MessageBubble({ onPauseAssistantTts: () => void; onResumeAssistantTts: () => void; onInterruptAssistantTts: () => void; + /** 切换到另一条助手分段朗读前,仅停止本地播放(不发 tts_cancel) */ + onPreemptAssistantPlayback: () => void; onReplayAssistantTts: (messageId: string, urls: string[]) => void; + /** 当前有助手 TTS 在播或已暂停(不含用户语音气泡) */ + assistantPlaybackEngaged: boolean; bubbleTextStyle?: TextStyle; voiceDurationTextStyle?: TextStyle; readAloudIconSize: number; @@ -284,8 +281,7 @@ function MessageBubble({ !isUser && !isVoice && playbackKind !== 'voice' && - (playbackRefListKey === listKey || - playbackMessageRefMatchesMessage(playbackRefListKey, item.id)); + playbackListKeyMatchesBubble(playbackRefListKey, listKey, item.id); const playbackEngaged = playbackIsPlaying || playbackIsPaused; const isThisBubbleActiveTts = matchesThisMessageForTts && playbackEngaged; @@ -335,19 +331,26 @@ function MessageBubble({ onPauseAssistantTts(); } else if (isThisBubbleTtsPaused) { onResumeAssistantTts(); - } else if (ttsUrlThisPart) { - onReplayAssistantTts(listKey, [ttsUrlThisPart]); - } else if (durableAssistantId) { - const ok = requestAssistantSegmentTts({ - assistantMessageId: durableAssistantId, - segmentIndex: assistantSegmentIndex, - segmentText: item.content, - }); - if (!ok) { - Alert.alert('', t('readAloudRequestFailed')); - } } else { - Alert.alert('', t('readAloudNoMessageId')); + const switchingSegment = + assistantPlaybackEngaged && !isThisBubbleActiveTts; + if (switchingSegment) { + onPreemptAssistantPlayback(); + } + if (ttsUrlThisPart) { + onReplayAssistantTts(listKey, [ttsUrlThisPart]); + } else if (durableAssistantId) { + const ok = requestAssistantSegmentTts({ + assistantMessageId: durableAssistantId, + segmentIndex: assistantSegmentIndex, + segmentText: item.content, + }); + if (!ok) { + Alert.alert('', t('readAloudRequestFailed')); + } + } else { + Alert.alert('', t('readAloudNoMessageId')); + } } }} style={({ pressed }) => [ @@ -432,7 +435,7 @@ function MessageBubble({ ) : ( {assistantTextBubbleBody} - {isThisBubbleActiveTts ? ( + {isThisBubbleTtsPlaying ? ( [ @@ -1470,6 +1473,14 @@ export default function ConversationScreen() { pausePlayback(); }, [pausePlayback]); + const handlePreemptAssistantPlayback = useCallback(() => { + void stop(); + }, [stop]); + + const assistantPlaybackEngaged = + (playerStatus === 'playing' || playerStatus === 'paused') && + currentPlaybackItem?.kind !== 'voice'; + const handleResumeAssistantPlayback = useCallback(() => { void resumePlayback(); }, [resumePlayback]); @@ -1918,7 +1929,9 @@ export default function ConversationScreen() { onResumeAssistantTts={handleResumeAssistantPlayback} onPlayVoiceExclusive={handlePlayVoiceExclusive} onInterruptAssistantTts={handleInterruptAssistantTts} + onPreemptAssistantPlayback={handlePreemptAssistantPlayback} onReplayAssistantTts={handleReplayAssistantTts} + assistantPlaybackEngaged={assistantPlaybackEngaged} bubbleTextStyle={chatBubbleTextStyle} voiceDurationTextStyle={chatVoiceDurationStyle} readAloudIconSize={chatReadAloudIconSize} diff --git a/app-expo/src/features/conversation/message-split.ts b/app-expo/src/features/conversation/message-split.ts index 45b1348..0a4c432 100644 --- a/app-expo/src/features/conversation/message-split.ts +++ b/app-expo/src/features/conversation/message-split.ts @@ -50,6 +50,56 @@ export function assistantSegmentMessageId( return `${assistantMessageId}_seg_${segmentIndex}`; } +const ASSISTANT_SPLIT_LIST_KEY_RE = + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_(?:seg|part)_(\d+)$/i; + +/** + * 解析展平气泡 `uuid_part_n` 或播放队列 `uuid_seg_n` 的落库 id 与段下标。 + */ +export function parseAssistantSplitListKey(listKey: string | undefined): { + messageId: string; + segmentIndex: number; +} | null { + if (!listKey?.length) return null; + const m = ASSISTANT_SPLIT_LIST_KEY_RE.exec(listKey); + if (m) { + return { messageId: m[1]!, segmentIndex: Number(m[2]) }; + } + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + listKey, + ) + ) { + return { messageId: listKey, segmentIndex: 0 }; + } + return null; +} + +/** 播放中的 `messageRef.listKey` 是否与当前气泡(含 `_part_` / `_seg_`)为同一段 */ +export function playbackListKeyMatchesBubble( + playbackListKey: string | undefined, + bubbleListKey: string, + messageItemId: string, +): boolean { + if (!playbackListKey?.length) return false; + if (playbackListKey === bubbleListKey) return true; + + const playback = parseAssistantSplitListKey(playbackListKey); + const bubble = + parseAssistantSplitListKey(bubbleListKey) ?? + parseAssistantSplitListKey(messageItemId); + if (playback && bubble) { + return ( + playback.messageId === bubble.messageId && + playback.segmentIndex === bubble.segmentIndex + ); + } + if (playbackListKey === messageItemId) { + return !playback || playback.segmentIndex === 0; + } + return false; +} + /** 历史/已落库消息:拆成非空片段,各渲染为一个气泡 */ export function splitMessageParts(content: string): string[] { return splitToPartsNormalized(String(content ?? '')); diff --git a/app-expo/src/features/voice/hooks/use-player.ts b/app-expo/src/features/voice/hooks/use-player.ts index edee04b..d260304 100644 --- a/app-expo/src/features/voice/hooks/use-player.ts +++ b/app-expo/src/features/voice/hooks/use-player.ts @@ -3,25 +3,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { audioFocus } from '@/core/audio/audio-focus'; -import type { PlaybackItem, PlayerStatus } from '../types'; +import { parseAssistantSplitListKey } from '@/features/conversation/message-split'; -/** - * `handleTtsSegment` 使用 `assistantSegmentMessageId` → `{uuid}_seg_{n}`; - * 展平气泡使用 `{uuid}_part_{n}`。同一条落库助手消息上的连续分段应用入队续播, - * 而不是「暂停后又到一条 tts_auto 就整轨切换成最新」——否则多段朗读只会听到最后一段。 - */ -function parseAssistantSplitListKey(listKey: string | undefined): { - messageId: string; - segmentIndex: number; -} | null { - if (!listKey) return null; - const m = - /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_(?:seg|part)_(\d+)$/i.exec( - listKey, - ); - if (!m) return null; - return { messageId: m[1]!, segmentIndex: Number(m[2]) }; -} +import type { PlaybackItem, PlayerStatus } from '../types'; function isLaterSegmentOfSameAssistantBubble( current: PlaybackItem | null | undefined, diff --git a/app-expo/tests/features/conversation/message-split.test.ts b/app-expo/tests/features/conversation/message-split.test.ts index 151595a..9bada8e 100644 --- a/app-expo/tests/features/conversation/message-split.test.ts +++ b/app-expo/tests/features/conversation/message-split.test.ts @@ -2,6 +2,8 @@ import { assistantSegmentMessageId, lastSegmentPreview, normalizeAssistantContentForSplit, + parseAssistantSplitListKey, + playbackListKeyMatchesBubble, splitMessageParts, splitStreamingSegments, } from '@/features/conversation/message-split'; @@ -75,6 +77,28 @@ describe('message-split', () => { expect(assistantSegmentMessageId('uuid-a', 1)).toBe('uuid-a_seg_1'); }); + it('playbackListKeyMatchesBubble aligns seg playback with part listKey', () => { + const uuid = '78b32c06-d2f9-453b-9cc4-354e68fbcb2d'; + expect( + playbackListKeyMatchesBubble( + `${uuid}_seg_1`, + `${uuid}_part_1`, + uuid, + ), + ).toBe(true); + expect( + playbackListKeyMatchesBubble( + `${uuid}_seg_0`, + `${uuid}_part_1`, + uuid, + ), + ).toBe(false); + expect(parseAssistantSplitListKey(`${uuid}_part_0`)).toEqual({ + messageId: uuid, + segmentIndex: 0, + }); + }); + it('normalizeAssistantContentForSplit maps fullwidth brackets', () => { expect(normalizeAssistantContentForSplit('[x]')).toBe('[x]'); expect(normalizeAssistantContentForSplit('【x】')).toBe('[x]');