修复:CI 部署环境与 ref 错配、迁移碎片化、图片意图 source_span、章节物化脏版式、会话历史与本地语音不一致
新增:TTS 上传 COS 与分片、章节 reading_segments 物化与快照、markdown 清洗、会话消息 repository、语音 store 重构与相关测试
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user