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:
@@ -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,7 +331,13 @@ function MessageBubble({
|
||||
onPauseAssistantTts();
|
||||
} else if (isThisBubbleTtsPaused) {
|
||||
onResumeAssistantTts();
|
||||
} else if (ttsUrlThisPart) {
|
||||
} else {
|
||||
const switchingSegment =
|
||||
assistantPlaybackEngaged && !isThisBubbleActiveTts;
|
||||
if (switchingSegment) {
|
||||
onPreemptAssistantPlayback();
|
||||
}
|
||||
if (ttsUrlThisPart) {
|
||||
onReplayAssistantTts(listKey, [ttsUrlThisPart]);
|
||||
} else if (durableAssistantId) {
|
||||
const ok = requestAssistantSegmentTts({
|
||||
@@ -349,6 +351,7 @@ function MessageBubble({
|
||||
} else {
|
||||
Alert.alert('', t('readAloudNoMessageId'));
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.readAloudButton,
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ?? ''));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]');
|
||||
|
||||
Reference in New Issue
Block a user