feat(api+app): 对话阶段化、回忆录流水线与客户端会话体验

- DB: segments 用户输入文本(Alembic 0002)
- Chat: 阶段检测/阶段提示/回复限制,编排与访谈/画像 prompts 调整
- Memoir: 忠实度检查 agent,叙事与分类等链路更新
- Core: agent 日志、Alembic 启动、LangChain/日志/配置等
- Story: time_hints;Memory 检索与相关测试
- Expo: 助手头像、会话页与消息拆分、实时会话与文案/i18n
- Docs/scripts/tests: 迁移脚本、LLM JSON/记忆检索文档、新增单测
This commit is contained in:
Kevin
2026-03-26 12:13:36 +08:00
parent 49b089354c
commit a3f61fcc0f
94 changed files with 3332 additions and 672 deletions

View File

@@ -29,6 +29,10 @@ import { Text } from '@/components/ui/text';
import { ScreenHeader } from '@/components/screen-header';
import { useThemeColors } from '@/hooks/use-theme-colors';
import { useMessages, useRealtimeSession } from '@/features/conversation/hooks';
import {
splitMessageParts,
splitStreamingSegments,
} from '@/features/conversation/message-split';
import type { MessageItem } from '@/features/conversation/types';
import { isVoiceMessage } from '@/features/conversation/types';
import { usePlayer } from '@/features/voice/hooks/use-player';
@@ -50,28 +54,18 @@ const CHAT_COLORS = {
errorRed: '#ba1a1a',
};
/** 后端用 [SPLIT] 分隔多条消息,需按块渲染为多个气泡 */
const SPLIT_DELIMITER = '[SPLIT]';
/** 聊天输入框:单行内容高度与最多约 4 行(与 lineHeight 对齐) */
const CHAT_INPUT_LINE_H = 22;
const CHAT_INPUT_MAX_H = CHAT_INPUT_LINE_H * 4;
function splitContent(content: string): string[] {
return content
.split(SPLIT_DELIMITER)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
const AGENT_AVATAR =
'https://lh3.googleusercontent.com/aida-public/AB6AXuC2cLdqTitOjFBnGTNsoJGh45K9zvV89vxB0EEHh0DT-YlbiOVyTuSmni5SO2eDOMC1HrW8cuhj29_KJJJFMV8aEPLHCwQgGA006Q9EfoDtEHP4GnzB4SEnT8bjCk6lQwjgh2Qp5dvq8oQTU5aQnKXtqOTAIIw1I3GWYlKimGa7Zqw4lnax84XRRGeYp6wj_1B9WsXuDvY7mNHIN7y_Tw0-TbRK1MGMHgd_nMNEqoqUCX6VAaDF2BjLB7BQnzIql8_8mw0TPaw1R3wO';
/** 与 archive/app-ios-react-app 中 `app-android/.../drawable/avatar_assistant.png` 同源(岁月知己) */
const AGENT_AVATAR = require('@/assets/images/avatar-assistant.png');
const USER_AVATAR =
'https://lh3.googleusercontent.com/aida-public/AB6AXuAMCjDBVhsUUXRAz9AGYejbTGoEYhzyiggYt_QIFqHCc3odRcBPNRhsE2Klg7gOeOV9V_qOy5qPqjU0GmpfgjGAWKGXZCizwRVz96N0n1IFMx4JH7QwV81zQsaVvCdJct_uABUBEawhncvQcbl0jUt_EUlNgzB-gIgUS_oLlT1TtRb8S5s7sAqwLRdGBa61yxL1X1iSWSFIn5N-WPIDs_vpCgS47q9SQjkT1q7VKvPzHzTiGF1bwVvjB7Bl2JgtaIUj6rkwlLbPG6xb';
type InputMode = 'text' | 'voice';
/** 展平消息列表assistant 消息按 [SPLIT] 拆成多条,每条一个 listKey */
/** 展平消息列表assistant 消息按 [SPLIT] 边界拆成多条,每条一个 listKey */
function flattenMessagesForList(
messages: MessageItem[],
): (MessageItem & { listKey: string })[] {
@@ -80,7 +74,10 @@ function flattenMessagesForList(
if (msg.senderType === 'user') {
result.push({ ...msg, listKey: msg.id });
} else {
const parts = splitContent(msg.content);
const parts = splitMessageParts(msg.content);
if (parts.length === 0) {
continue;
}
if (parts.length > 1) {
parts.forEach((part, i) => {
result.push({
@@ -126,20 +123,14 @@ function MessageBubble({
]}
>
<Image
source={{ uri: isUser ? USER_AVATAR : AGENT_AVATAR }}
source={isUser ? { uri: USER_AVATAR } : AGENT_AVATAR}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
alt={isUser ? meLabel : agentName}
/>
</View>
<View style={[styles.bubbleColumn, isUser && styles.bubbleColumnEnd]}>
<Text
style={[
styles.nickname,
isUser ? styles.nicknameUser : styles.nicknameAgent,
]}
>
{isUser ? meLabel : agentName}
</Text>
{isVoice ? (
<View
style={[
@@ -198,10 +189,13 @@ function StreamingBubbles({
isComplete: boolean;
agentName: string;
}) {
const parts = splitContent(streamingText);
const completedParts = parts.length > 1 ? parts.slice(0, -1) : [];
const segments = splitStreamingSegments(streamingText);
const completedParts =
segments.length > 1
? segments.slice(0, -1).filter((s) => s.length > 0)
: [];
const streamingPart =
parts.length > 1 ? parts[parts.length - 1]! : (parts[0] ?? streamingText);
segments.length > 0 ? segments[segments.length - 1]! : streamingText;
return (
<View>
@@ -212,15 +206,14 @@ function StreamingBubbles({
>
<View style={[styles.avatarWrapper, styles.avatarWrapperAgent]}>
<Image
source={{ uri: AGENT_AVATAR }}
source={AGENT_AVATAR}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
alt={agentName}
/>
</View>
<View style={[styles.bubbleColumn]}>
<Text style={[styles.nickname, styles.nicknameAgent]}>
{agentName}
</Text>
<View style={[styles.bubble, styles.bubbleAgent]}>
<Text
selectable
@@ -235,15 +228,14 @@ function StreamingBubbles({
<View style={[styles.messageRow, styles.streamingRow]}>
<View style={[styles.avatarWrapper, styles.avatarWrapperAgent]}>
<Image
source={{ uri: AGENT_AVATAR }}
source={AGENT_AVATAR}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
alt={agentName}
/>
</View>
<View style={[styles.bubbleColumn]}>
<Text style={[styles.nickname, styles.nicknameAgent]}>
{agentName}
</Text>
<View style={[styles.bubble, styles.bubbleAgent]}>
<Text
selectable
@@ -294,6 +286,7 @@ function VoiceMessageBubble({
onPress={handlePlayPause}
style={({ pressed }) => [
styles.voicePlayButton,
isUser ? styles.voicePlayButtonUser : styles.voicePlayButtonAgent,
pressed && { opacity: 0.7 },
]}
disabled={!audioUri}
@@ -303,7 +296,7 @@ function VoiceMessageBubble({
<Icon
as={isPlaying ? Pause : Play}
size={24}
color={isUser ? CHAT_COLORS.onSurface : CHAT_COLORS.onPrimary}
color={isUser ? CHAT_COLORS.onPrimary : CHAT_COLORS.primary}
/>
</Pressable>
<Text
@@ -679,6 +672,21 @@ export default function ConversationScreen() {
const listRef = useRef<FlatList>(null);
/** 底部输入区(含连接提示 + 输入条)高度,用于多行输入增高时把列表滚到底,避免挡住最新消息 */
const composerBlockHeightRef = useRef<number | null>(null);
/** 连接中connecting时点发送排队连上后自动发出 */
const pendingTextSendRef = useRef<string | null>(null);
const connectingSendTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const CONNECTING_SEND_TIMEOUT_MS = 15_000;
const PENDING_SEND_FLUSH_MS = 50;
const clearConnectingSendTimeout = useCallback(() => {
if (connectingSendTimeoutRef.current) {
clearTimeout(connectingSendTimeoutRef.current);
connectingSendTimeoutRef.current = null;
}
}, []);
const flattenedData = flattenMessagesForList(messages ?? []);
@@ -741,16 +749,60 @@ export default function ConversationScreen() {
[scrollListToEndAfterComposerLayout],
);
useEffect(() => {
return () => {
clearConnectingSendTimeout();
};
}, [clearConnectingSendTimeout]);
useEffect(() => {
pendingTextSendRef.current = null;
clearConnectingSendTimeout();
}, [id, clearConnectingSendTimeout]);
/** 连接中排队:一旦变为 connected短延迟后发送给 WebSocket onopen 一点时间 */
useEffect(() => {
if (connectionState !== 'connected') return;
const text = pendingTextSendRef.current;
if (!text) return;
const t = setTimeout(() => {
if (pendingTextSendRef.current !== text) return;
pendingTextSendRef.current = null;
clearConnectingSendTimeout();
sendText(text);
}, PENDING_SEND_FLUSH_MS);
return () => clearTimeout(t);
}, [connectionState, sendText, clearConnectingSendTimeout]);
/** 排队期间若变为断开,把内容退回输入框,并取消连接等待超时 */
useEffect(() => {
if (connectionState !== 'disconnected') return;
clearConnectingSendTimeout();
const text = pendingTextSendRef.current;
if (!text) return;
pendingTextSendRef.current = null;
setInput(text);
}, [connectionState, clearConnectingSendTimeout]);
const handleSend = () => {
const text = input.trim();
if (!text) return;
if (connectionState !== 'connected') {
Alert.alert(
t('chatUnavailableTitle'),
connectionState === 'connecting'
? t('chatUnavailableConnecting')
: t('chatUnavailableDisconnected'),
);
if (connectionState === 'disconnected') {
Alert.alert(t('chatUnavailableTitle'), t('chatUnavailableDisconnected'));
return;
}
if (connectionState === 'connecting') {
pendingTextSendRef.current = text;
setInput('');
setInputResetKey((k) => k + 1);
clearConnectingSendTimeout();
connectingSendTimeoutRef.current = setTimeout(() => {
connectingSendTimeoutRef.current = null;
if (pendingTextSendRef.current !== text) return;
pendingTextSendRef.current = null;
setInput(text);
Alert.alert(t('chatUnavailableTitle'), t('chatQueueSendTimeout'));
}, CONNECTING_SEND_TIMEOUT_MS);
return;
}
sendText(text);
@@ -758,6 +810,9 @@ export default function ConversationScreen() {
setInputResetKey((k) => k + 1);
};
/** 仅完全断开时禁用发送/语音;连接中可点发送(排队) */
const composerDisabled = connectionState === 'disconnected';
const connectionLabel =
connectionState === 'connected'
? t('connectionConnected')
@@ -890,7 +945,7 @@ export default function ConversationScreen() {
tapToStartLabel={t('tapToStartRecording')}
tapToEndLabel={t('tapToEndRecording')}
cancelRecordingLabel={t('cancelRecording')}
disabled={connectionState !== 'connected'}
disabled={composerDisabled}
onInputDisplayHeightChange={scrollListToEndAfterComposerLayout}
/>
</View>
@@ -974,21 +1029,24 @@ const styles = StyleSheet.create({
avatarWrapper: {
width: 40,
height: 40,
borderRadius: 20,
flexShrink: 0,
borderWidth: 2,
borderWidth: 1,
overflow: 'hidden',
},
avatarWrapperAgent: {
backgroundColor: CHAT_COLORS.secondaryContainer,
borderColor: CHAT_COLORS.primary,
borderColor: 'rgba(129, 119, 166, 0.35)',
},
avatarWrapperUser: {
backgroundColor: CHAT_COLORS.primaryFixed,
borderColor: CHAT_COLORS.onSurfaceVariant,
borderColor: 'rgba(141, 140, 144, 0.35)',
},
/** 远程图在 Android 上若用 100% 尺寸可能解析为 0×0需写死数值见 Expo 文档) */
avatarImage: {
width: '100%',
height: '100%',
width: 40,
height: 40,
borderRadius: 20,
},
bubbleColumn: {
maxWidth: '80%',
@@ -998,44 +1056,45 @@ const styles = StyleSheet.create({
alignItems: 'flex-end',
alignSelf: 'flex-end',
},
nickname: {
fontSize: 16,
fontWeight: '700',
marginBottom: 2,
},
nicknameAgent: {
color: CHAT_COLORS.primary,
},
nicknameUser: {
color: CHAT_COLORS.onSurfaceVariant,
},
/** 微信式:左白右主题色,靠头像一侧圆角略小;轻阴影替代像素风描边 */
bubble: {
padding: 12,
borderWidth: 2,
shadowOffset: { width: 4, height: 4 },
shadowOpacity: 1,
shadowRadius: 0,
paddingHorizontal: 14,
paddingVertical: 10,
maxWidth: '100%',
borderWidth: 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 4,
elevation: 1,
},
bubbleAgent: {
backgroundColor: CHAT_COLORS.primary,
borderColor: CHAT_COLORS.primaryBorder,
shadowColor: CHAT_COLORS.primaryBorder,
backgroundColor: '#ffffff',
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
borderBottomRightRadius: 12,
borderBottomLeftRadius: 4,
},
bubbleUser: {
backgroundColor: '#ffffff',
borderColor: CHAT_COLORS.onSurfaceVariant,
shadowColor: CHAT_COLORS.outline,
backgroundColor: CHAT_COLORS.primary,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
borderBottomLeftRadius: 12,
borderBottomRightRadius: 4,
shadowColor: CHAT_COLORS.primaryBorder,
shadowOpacity: 0.12,
elevation: 2,
},
bubbleText: {
fontSize: 22,
lineHeight: 34,
fontSize: 17,
lineHeight: 24,
fontWeight: '400',
},
bubbleTextAgent: {
color: CHAT_COLORS.onPrimary,
color: CHAT_COLORS.onSurface,
},
bubbleTextUser: {
color: CHAT_COLORS.onSurface,
color: CHAT_COLORS.onPrimary,
},
voiceMessageBubble: {
flexDirection: 'row',
@@ -1051,17 +1110,22 @@ const styles = StyleSheet.create({
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.2)',
},
voicePlayButtonUser: {
backgroundColor: 'rgba(255, 255, 255, 0.22)',
},
voicePlayButtonAgent: {
backgroundColor: 'rgba(129, 119, 166, 0.12)',
},
voiceDurationText: {
fontSize: 18,
fontWeight: '500',
},
voiceDurationTextUser: {
color: CHAT_COLORS.onSurface,
color: CHAT_COLORS.onPrimary,
},
voiceDurationTextAgent: {
color: CHAT_COLORS.onPrimary,
color: CHAT_COLORS.onSurface,
},
inputBarWrapper: {
backgroundColor: CHAT_COLORS.surface,

View File

@@ -1,3 +1,4 @@
import { Image } from 'expo-image';
import { router } from 'expo-router';
import React from 'react';
import { Alert, Pressable, ScrollView, View } from 'react-native';
@@ -19,6 +20,10 @@ import {
} from '@/features/conversation/hooks';
import type { ConversationListItem } from '@/features/conversation/types';
/** 与聊天页一致archive Android `avatar_assistant` */
const DEFAULT_ASSISTANT_AVATAR = require('@/assets/images/avatar-assistant.png');
const LIST_AVATAR_SIZE = 56;
function formatRelativeConversationListTime(
timestamp: number,
t: TFunction<'conversation'>,
@@ -83,21 +88,50 @@ function ConversationCard({
typography.lineHeightTight,
typography.titleLarge + 4,
);
const avatarBg = item.isDefaultAssistant ? 'bg-primary' : 'bg-secondary';
const avatarIconClass = item.isDefaultAssistant
? 'text-primary-foreground'
: 'text-secondary-foreground';
const renderAvatar = () => {
if (item.isDefaultAssistant) {
return (
<View className="h-14 w-14 shrink-0 overflow-hidden rounded-lg">
<Image
source={DEFAULT_ASSISTANT_AVATAR}
style={{
width: LIST_AVATAR_SIZE,
height: LIST_AVATAR_SIZE,
}}
contentFit="cover"
cachePolicy="memory-disk"
/>
</View>
);
}
if (item.avatarUrl) {
return (
<View className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-secondary">
<Image
source={{ uri: item.avatarUrl }}
style={{
width: LIST_AVATAR_SIZE,
height: LIST_AVATAR_SIZE,
}}
contentFit="cover"
cachePolicy="memory-disk"
/>
</View>
);
}
return (
<View className="h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-secondary">
<Icon as={User} className="text-secondary-foreground" size={28} />
</View>
);
};
return (
<Pressable
className="flex-row items-start gap-6 rounded-xl border border-border bg-card p-6 active:bg-muted"
onPress={onPress}
>
<View
className={`h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-lg ${avatarBg}`}
>
<Icon as={User} className={avatarIconClass} size={28} />
</View>
{renderAvatar()}
<View className="min-w-0 flex-1 gap-2">
<View className="flex-row items-start justify-between gap-2">
<Text

View File

@@ -0,0 +1,31 @@
/**
* 与后端 / LLM 约定:多条助手消息用 [SPLIT] 分隔(大小写不敏感)。
* 分隔符为边界,不包含在气泡正文中。
*/
export const MESSAGE_SPLIT_REGEX = /\[SPLIT\]/i;
/** 历史/已落库消息:拆成非空片段,各渲染为一个气泡 */
export function splitMessageParts(content: string): string[] {
return content
.split(MESSAGE_SPLIT_REGEX)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
/**
* 流式输出:保留空尾段,使「第一条已结束 + 分隔符已出现、第二条尚未有字」时
* 仍能拆成两段(上一段完成气泡 + 下一段空流式气泡)。
*/
export function splitStreamingSegments(content: string): string[] {
return content.split(MESSAGE_SPLIT_REGEX).map((s) => s.trim());
}
/** 会话列表预览:取最后一条子消息的前若干字 */
export function lastSegmentPreview(content: string, maxLen: number): string {
const parts = splitMessageParts(content);
if (parts.length === 0) {
return '';
}
return parts[parts.length - 1]!.slice(0, maxLen);
}

View File

@@ -8,6 +8,7 @@ import {
import type { WsConnectionState, WsEvent } from '@/core/ws/types';
import { handleWsEvent } from './event-handlers';
import { lastSegmentPreview } from './message-split';
import { conversationKeys } from './query-keys';
import type { ConversationListItem, MessageItem } from './types';
@@ -196,7 +197,7 @@ export class RealtimeSession {
item.id === this.conversationId
? {
...item,
latestMessagePreview: fullText.slice(0, 50),
latestMessagePreview: lastSegmentPreview(fullText, 50),
latestMessageTime: Date.now(),
}
: item,

View File

@@ -65,6 +65,7 @@ interface Resources {
agentName: 'Life Echo';
cancel: 'Cancel';
cancelRecording: 'Cancel recording';
chatQueueSendTimeout: 'Connection timed out. Check your network and try again.';
chatTitle: 'Conversation';
chatUnavailableConnecting: 'Reconnecting now. You can keep typing and send once the connection is back.';
chatUnavailableDisconnected: 'Connection lost. You can keep typing and send after reconnecting.';
@@ -77,7 +78,7 @@ interface Resources {
createError: 'Unable to create conversation. Please check your network and try again.';
delete: 'Delete';
deleteConversation: 'Delete Conversation';
emptyGreetingSubtitle: 'Chat with Echo and record your stories.';
emptyGreetingSubtitle: 'Chat with your companion and record your stories.';
greetingTitle: 'Say Hello';
inputPlaceholder: 'Type a message...';
inputPlaceholderVoice: 'Type here or hold the mic to speak...';
@@ -85,7 +86,7 @@ interface Resources {
recentChats: 'Recent Chats';
recordingPermissionDenied: 'Microphone permission is required to record';
send: 'Send';
startNewSubtitle: 'Capture a new memory or share your thoughts with Echo.';
startNewSubtitle: 'Capture a new memory or share your thoughts with your companion.';
switchToText: 'Switch to text input';
switchToVoice: 'Switch to voice input';
tapToEndRecording: 'Tap to end';
@@ -130,7 +131,7 @@ interface Resources {
typography: 'Typography';
};
continueWriting: 'Continue Writing';
emptySubtitle: 'Chat with Echo to record your stories';
emptySubtitle: 'Chat with your companion to record your stories';
emptyTitle: 'No memoir yet';
frameworkChapters: {
chapter1: 'Childhood and upbringing';

View File

@@ -4,6 +4,7 @@
"chatUnavailableTitle": "Chat unavailable",
"chatUnavailableConnecting": "Reconnecting now. You can keep typing and send once the connection is back.",
"chatUnavailableDisconnected": "Connection lost. You can keep typing and send after reconnecting.",
"chatQueueSendTimeout": "Connection timed out. Check your network and try again.",
"confirm": "OK",
"cancel": "Cancel",
"delete": "Delete",
@@ -15,7 +16,7 @@
"connectionConnected": "Connected",
"connectionConnecting": "Connecting...",
"connectionDisconnected": "Disconnected",
"emptyGreetingSubtitle": "Chat with Echo and record your stories.",
"emptyGreetingSubtitle": "Chat with your companion and record your stories.",
"greetingTitle": "Say Hello",
"inputPlaceholder": "Type a message...",
"inputPlaceholderVoice": "Type here or hold the mic to speak...",
@@ -23,7 +24,7 @@
"recentChats": "Recent Chats",
"recordingPermissionDenied": "Microphone permission is required to record",
"send": "Send",
"startNewSubtitle": "Capture a new memory or share your thoughts with Echo.",
"startNewSubtitle": "Capture a new memory or share your thoughts with your companion.",
"switchToText": "Switch to text input",
"switchToVoice": "Switch to voice input",
"tapToEndRecording": "Tap to end",

View File

@@ -34,7 +34,7 @@
"typography": "Typography"
},
"continueWriting": "Continue Writing",
"emptySubtitle": "Chat with Echo to record your stories",
"emptySubtitle": "Chat with your companion to record your stories",
"emptyTitle": "No memoir yet",
"pageTitle": "Memoir",
"readMemory": "Read Memory",

View File

@@ -4,6 +4,7 @@
"chatUnavailableTitle": "聊天暂不可用",
"chatUnavailableConnecting": "正在重新连接。你仍可继续输入,恢复后再发送。",
"chatUnavailableDisconnected": "当前连接已断开。你仍可先输入,连接恢复后再发送。",
"chatQueueSendTimeout": "连接等待超时,请检查网络后重试。",
"confirm": "知道了",
"cancel": "取消",
"delete": "删除",
@@ -15,7 +16,7 @@
"connectionConnected": "已连接",
"connectionConnecting": "连接中...",
"connectionDisconnected": "未连接",
"emptyGreetingSubtitle": "和 Echo 聊聊,记录你的故事。",
"emptyGreetingSubtitle": "和岁月知己聊聊,记录你的故事。",
"greetingTitle": "打个招呼",
"inputPlaceholder": "输入消息...",
"inputPlaceholderVoice": "点击这里输入,或者按住左边说话...",
@@ -23,7 +24,7 @@
"recentChats": "最近对话",
"recordingPermissionDenied": "需要麦克风权限才能录音",
"send": "发送",
"startNewSubtitle": "记录新回忆,或与 Echo 分享你的想法。",
"startNewSubtitle": "记录新回忆,或与岁月知己分享你的想法。",
"switchToText": "切换到文字输入",
"switchToVoice": "切换到语音输入",
"tapToEndRecording": "点击结束",

View File

@@ -34,7 +34,7 @@
"typography": "字体"
},
"continueWriting": "继续写作",
"emptySubtitle": "与 Echo 聊天,记录你的故事",
"emptySubtitle": "与岁月知己聊天,记录你的故事",
"emptyTitle": "还没有回忆录",
"pageTitle": "回忆录",
"readMemory": "阅读回忆",