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:
BIN
app-expo/assets/images/avatar-assistant.png
Normal file
BIN
app-expo/assets/images/avatar-assistant.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
31
app-expo/src/features/conversation/message-split.ts
Normal file
31
app-expo/src/features/conversation/message-split.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "点击结束",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"typography": "字体"
|
||||
},
|
||||
"continueWriting": "继续写作",
|
||||
"emptySubtitle": "与 Echo 聊天,记录你的故事",
|
||||
"emptySubtitle": "与岁月知己聊天,记录你的故事",
|
||||
"emptyTitle": "还没有回忆录",
|
||||
"pageTitle": "回忆录",
|
||||
"readMemory": "阅读回忆",
|
||||
|
||||
27
app-expo/tests/features/conversation/message-split.test.ts
Normal file
27
app-expo/tests/features/conversation/message-split.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
lastSegmentPreview,
|
||||
splitMessageParts,
|
||||
splitStreamingSegments,
|
||||
} from '@/features/conversation/message-split';
|
||||
|
||||
describe('message-split', () => {
|
||||
it('splitMessageParts is case-insensitive on delimiter', () => {
|
||||
expect(splitMessageParts('a [SPLIT] b')).toEqual(['a', 'b']);
|
||||
expect(splitMessageParts('a [split] b')).toEqual(['a', 'b']);
|
||||
expect(splitMessageParts('a [Split] b')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('splitMessageParts trims and drops empty segments', () => {
|
||||
expect(splitMessageParts(' x [SPLIT] y ')).toEqual(['x', 'y']);
|
||||
expect(splitMessageParts('[SPLIT]only')).toEqual(['only']);
|
||||
});
|
||||
|
||||
it('splitStreamingSegments keeps empty tail after delimiter', () => {
|
||||
expect(splitStreamingSegments('first [SPLIT]')).toEqual(['first', '']);
|
||||
});
|
||||
|
||||
it('lastSegmentPreview uses last non-empty part', () => {
|
||||
expect(lastSegmentPreview('a [SPLIT] b', 10)).toBe('b');
|
||||
expect(lastSegmentPreview('hello', 3)).toBe('hel');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user