fix(app-expo): allow read-aloud on other split segments while TTS paused

Match playback refs to the correct assistant segment so the interrupt overlay
does not block other bubbles, and preempt local playback when switching segments.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-19 14:55:10 +08:00
parent b22f1cd4c4
commit 3921c5ec24
4 changed files with 114 additions and 43 deletions

View File

@@ -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({
) : (
<View style={styles.assistantBubbleWrap}>
{assistantTextBubbleBody}
{isThisBubbleActiveTts ? (
{isThisBubbleTtsPlaying ? (
<Pressable
onPress={onInterruptAssistantTts}
style={({ pressed }) => [
@@ -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}

View File

@@ -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 ?? ''));

View File

@@ -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,

View File

@@ -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]');