修复:CI 部署环境与 ref 错配、迁移碎片化、图片意图 source_span、章节物化脏版式、会话历史与本地语音不一致

新增:TTS 上传 COS 与分片、章节 reading_segments 物化与快照、markdown 清洗、会话消息 repository、语音 store 重构与相关测试
This commit is contained in:
Kevin
2026-03-20 16:36:42 +08:00
parent 7317bf10cd
commit 8af37e5e8e
65 changed files with 1704 additions and 504 deletions

View File

@@ -1,14 +1,6 @@
import { Image } from 'expo-image';
import { useLocalSearchParams } from 'expo-router';
import {
Mic,
Pause,
Play,
PlusCircle,
Type,
Volume2,
X,
} from 'lucide-react-native';
import { Mic, Pause, Play, PlusCircle, Type, X } from 'lucide-react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type {
NativeSyntheticEvent,
@@ -37,10 +29,9 @@ import { ScreenHeader } from '@/components/screen-header';
import { useThemeColors } from '@/hooks/use-theme-colors';
import { useMessages, useRealtimeSession } from '@/features/conversation/hooks';
import type { MessageItem } from '@/features/conversation/types';
import { audioFocus } from '@/core/audio/audio-focus';
import { isVoiceMessage } from '@/features/conversation/types';
import { usePlayer } from '@/features/voice/hooks/use-player';
import { useRecorder } from '@/features/voice/hooks/use-recorder';
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
// Life-Echo chat colors (from HTML reference)
const CHAT_COLORS = {
@@ -109,13 +100,21 @@ function MessageBubble({
item,
agentName,
meLabel,
currentPlaybackUri,
playbackIsPlaying,
onPlayVoiceExclusive,
onPausePlayback,
}: {
item: MessageItem;
agentName: string;
meLabel: string;
currentPlaybackUri: string | null;
playbackIsPlaying: boolean;
onPlayVoiceExclusive: (uri: string) => void;
onPausePlayback: () => void;
}) {
const isUser = item.senderType === 'user';
const isVoice = item.messageType === 'voice';
const isVoice = isVoiceMessage(item);
return (
<View style={[styles.messageRow, isUser && styles.messageRowReverse]}>
@@ -140,19 +139,39 @@ function MessageBubble({
>
{isUser ? meLabel : agentName}
</Text>
<View
style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAgent,
]}
>
{isVoice ? (
{isVoice ? (
<View
style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAgent,
]}
>
<VoiceMessageBubble
durationSeconds={item.durationSeconds ?? 0}
audioUri={item.audioUri}
isUser={isUser}
isPlaying={
!!item.audioUri &&
playbackIsPlaying &&
currentPlaybackUri === item.audioUri
}
onPlayPress={() => {
if (!item.audioUri) return;
if (playbackIsPlaying && currentPlaybackUri === item.audioUri) {
onPausePlayback();
} else {
onPlayVoiceExclusive(item.audioUri);
}
}}
/>
) : (
</View>
) : (
<View
style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAgent,
]}
>
<Text
selectable
style={[
@@ -162,8 +181,8 @@ function MessageBubble({
>
{item.content}
</Text>
)}
</View>
</View>
)}
</View>
</View>
);
@@ -250,33 +269,18 @@ function VoiceMessageBubble({
durationSeconds,
audioUri,
isUser,
isPlaying,
onPlayPress,
}: {
durationSeconds: number;
audioUri?: string;
isUser: boolean;
isPlaying: boolean;
onPlayPress: () => void;
}) {
const player = useAudioPlayer(null);
const status = useAudioPlayerStatus(player);
useEffect(() => {
const { playing, currentTime, duration } = status;
const finished = !playing && duration > 0 && currentTime >= duration - 0.05;
if (finished) {
void audioFocus.release();
}
}, [status]);
const handlePlayPause = useCallback(async () => {
if (!audioUri) return;
if (status.playing) {
player.pause();
} else {
const acquired = await audioFocus.acquireForPlayback();
if (!acquired) return;
player.replace(audioUri);
player.play();
}
}, [audioUri, player, status.playing]);
const handlePlayPause = useCallback(() => {
onPlayPress();
}, [onPlayPress]);
return (
<View
@@ -292,11 +296,11 @@ function VoiceMessageBubble({
pressed && { opacity: 0.7 },
]}
disabled={!audioUri}
accessibilityLabel={status.playing ? 'Pause' : 'Play'}
accessibilityLabel={isPlaying ? 'Pause' : 'Play'}
accessibilityRole="button"
>
<Icon
as={status.playing ? Pause : Play}
as={isPlaying ? Pause : Play}
size={24}
color={isUser ? CHAT_COLORS.onSurface : CHAT_COLORS.onPrimary}
/>
@@ -609,12 +613,44 @@ export default function ConversationScreen() {
const { t } = useTranslation('conversation');
const { t: tApp } = useTranslation('app');
const { data: messages } = useMessages(id);
const { enqueueTtsAudio, status: playerStatus } = usePlayer();
const {
enqueue,
enqueueExclusive,
stop,
status: playerStatus,
currentSource,
} = usePlayer();
const handleTtsSegment = useCallback(
(p: { audioBase64?: string; audioUrl?: string }) => {
if (p.audioBase64) {
void enqueue({
uri: `data:audio/mp3;base64,${p.audioBase64}`,
label: 'TTS',
});
} else if (p.audioUrl) {
void enqueue({ uri: p.audioUrl, label: 'TTS' });
}
},
[enqueue],
);
const handlePlayVoiceExclusive = useCallback(
(uri: string) => {
void enqueueExclusive({ uri, label: 'voice' });
},
[enqueueExclusive],
);
const handlePausePlayback = useCallback(() => {
void stop();
}, [stop]);
const { connectionState, streamingMessage, sendText, sendVoiceMessage } =
useRealtimeSession({
conversationId: id ?? '',
enabled: !!id,
onTtsAudio: enqueueTtsAudio,
onTtsSegment: handleTtsSegment,
});
const handleRecordingComplete = useCallback(
@@ -643,30 +679,6 @@ export default function ConversationScreen() {
const onShow = (e: { endCoordinates: { height: number } }) => {
setIsKeyboardVisible(true);
setKeyboardHeight(e.endCoordinates.height);
// #region agent log
void fetch(
'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Debug-Session-Id': 'a82a4c',
},
body: JSON.stringify({
sessionId: 'a82a4c',
hypothesisId: 'H3',
location: 'ConversationScreen:keyboardShow',
message: 'keyboard open; composer uses insetBottom 0 when text+kb',
data: {
kbH: e.endCoordinates.height,
insetBottom: insets.bottom,
os: Platform.OS,
},
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
InteractionManager.runAfterInteractions(() => {
listRef.current?.scrollToEnd({ animated: true });
});
@@ -706,26 +718,6 @@ export default function ConversationScreen() {
sendText(text);
setInput('');
setInputResetKey((k) => k + 1);
// #region agent log
void fetch(
'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Debug-Session-Id': 'a82a4c',
},
body: JSON.stringify({
sessionId: 'a82a4c',
hypothesisId: 'H4',
location: 'ConversationScreen:handleSend',
message: 'cleared input + bumped textInputKey',
data: { sentLen: text.length },
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
};
const connectionLabel =
@@ -752,14 +744,6 @@ export default function ConversationScreen() {
title={
<View style={styles.headerTitleBlock}>
<Text style={styles.headerTitle}>{tApp('name')}</Text>
{playerStatus === 'playing' && (
<Icon
as={Volume2}
size={18}
color={CHAT_COLORS.primary}
style={{ marginRight: 6 }}
/>
)}
<View
style={[
styles.statusBadge,
@@ -795,6 +779,10 @@ export default function ConversationScreen() {
item={item}
agentName={t('agentName')}
meLabel={t('me')}
currentPlaybackUri={currentSource}
playbackIsPlaying={playerStatus === 'playing'}
onPlayVoiceExclusive={handlePlayVoiceExclusive}
onPausePlayback={handlePausePlayback}
/>
)}
onContentSizeChange={() =>
@@ -930,6 +918,7 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
gap: 10,
marginBottom: 16,
overflow: 'visible',
},
messageRowReverse: {
flexDirection: 'row-reverse',