feat(i18n): persist language preference and thread through chat, memoir, TTS

- Add users.language_preference (Alembic 0018, default zh); capture at signup/SMS
  only; expose on auth and profile APIs
- Lite English prompts for chat and memoir; localized stage labels and agent
  names (Life Echo / 岁月知己)
- Tencent TTS: language-aware synthesis, ModelType=1 for 501004, English chunking
- WebSocket pipeline: emit all AGENT_RESPONSE segments when TTS cancels; INFO logs
  for tts_this_turn and TTS decisions; on-demand TTS logging
- Expo: device language on auth, i18n tiers/agent name, [SPLIT] streaming UX fixes
- Tests for migration, prompts, pipeline, router tts_this_turn, reply segments

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-11 16:16:49 +08:00
parent 5ce29aad64
commit ccdc4e4277
64 changed files with 3233 additions and 208 deletions

View File

@@ -593,6 +593,13 @@ function StreamingBubbles({
const streamingPart =
segments.length > 0 ? segments[segments.length - 1]! : streamingText;
const streamingWithCursor = streamingPart + (!isComplete ? '▌' : '');
/**
* `splitStreamingSegments` 故意保留尾部空段以支持「上一段完成 + 下一段尚未到字」的
* 流式过渡。但 isComplete=true 时再渲染空尾段就会变成一只永不消失的「假装回复」气泡
* (含字面 [SPLIT] 残留时尤甚)。
*/
const showStreamingBubble =
!isComplete || streamingPart.trim().length > 0;
const inner = (
<>
@@ -627,49 +634,51 @@ function StreamingBubbles({
</View>
</View>
))}
<View style={[styles.messageRow, styles.streamingRow]}>
<View style={[styles.avatarWrapper, styles.avatarWrapperAgent]}>
<Image
source={AGENT_AVATAR}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
alt={agentName}
/>
{showStreamingBubble ? (
<View style={[styles.messageRow, styles.streamingRow]}>
<View style={[styles.avatarWrapper, styles.avatarWrapperAgent]}>
<Image
source={AGENT_AVATAR}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
alt={agentName}
/>
</View>
<View style={[styles.bubbleColumn]}>
{!isComplete ? (
<Animated.View
style={[
styles.bubble,
styles.bubbleAgent,
streamingTtsActive && styles.bubbleAgentTtsActive,
{ opacity: streamPulse },
]}
>
<ChatBubbleText
text={streamingWithCursor}
variant="assistant"
textStyle={bubbleTextStyle}
/>
</Animated.View>
) : (
<View
style={[
styles.bubble,
styles.bubbleAgent,
streamingTtsActive && styles.bubbleAgentTtsActive,
]}
>
<ChatBubbleText
text={streamingWithCursor}
variant="assistant"
textStyle={bubbleTextStyle}
/>
</View>
)}
</View>
</View>
<View style={[styles.bubbleColumn]}>
{!isComplete ? (
<Animated.View
style={[
styles.bubble,
styles.bubbleAgent,
streamingTtsActive && styles.bubbleAgentTtsActive,
{ opacity: streamPulse },
]}
>
<ChatBubbleText
text={streamingWithCursor}
variant="assistant"
textStyle={bubbleTextStyle}
/>
</Animated.View>
) : (
<View
style={[
styles.bubble,
styles.bubbleAgent,
streamingTtsActive && styles.bubbleAgentTtsActive,
]}
>
<ChatBubbleText
text={streamingWithCursor}
variant="assistant"
textStyle={bubbleTextStyle}
/>
</View>
)}
</View>
</View>
) : null}
</>
);

View File

@@ -136,22 +136,23 @@ function SettingRow({
);
}
function planDisplayName(
subscriptionType: string | undefined,
planName?: string,
) {
if (planName) return planName;
switch (subscriptionType) {
case 'free':
return 'Free';
case 'pro':
case 'premium':
return 'Pro';
case 'pro_plus':
return 'Pro+';
default:
return 'Free';
}
type TierI18nKey =
| 'tier.free'
| 'tier.pro'
| 'tier.pro_plus'
| 'tier.test';
const TIER_I18N_KEYS: Record<string, TierI18nKey> = {
free: 'tier.free',
pro: 'tier.pro',
premium: 'tier.pro',
pro_plus: 'tier.pro_plus',
test: 'tier.test',
};
function tierI18nKey(value: string | undefined): TierI18nKey | undefined {
if (!value) return undefined;
return TIER_I18N_KEYS[value];
}
export default function ProfileScreen() {
@@ -173,8 +174,13 @@ export default function ProfileScreen() {
changeDarkMode,
} = useAppSettings();
const tierLabel =
currentPlan?.plan_name ?? planDisplayName(user?.subscription_type);
const tierKey =
tierI18nKey(currentPlan?.plan_id) ??
tierI18nKey(currentPlan?.subscription_type) ??
tierI18nKey(user?.subscription_type);
const tierLabel = tierKey
? t(tierKey)
: (currentPlan?.plan_name ?? t('tier.free'));
const currentLanguageLabel =
hasLanguageOverride && language
? (languageOptions.find((o) => o.code === language)?.label ?? language)

View File

@@ -5,9 +5,11 @@ import { useCallback } from 'react';
import { AuthError } from '@/core/api/types';
import { tokenManager } from '@/core/auth/token-manager';
import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool';
import { getDeviceLanguage } from '@/i18n';
import { authApi } from './api';
import type {
LanguagePreference,
LoginRequest,
RegisterRequest,
SessionState,
@@ -19,6 +21,17 @@ import type {
UserInfo,
} from './types';
/**
* Resolve the device language to send at sign-up. Backend only persists
* language_preference on first user creation; subsequent logins ignore it.
*/
function withDeviceLanguage<T extends { language?: LanguagePreference }>(
body: T,
): T {
if (body.language) return body;
return { ...body, language: getDeviceLanguage() };
}
// ─── Query keys ───
export const authKeys = {
@@ -139,7 +152,8 @@ export function useSmsLogin() {
const onSuccess = usePostAuthSetup();
return useMutation({
mutationFn: (body: SmsLoginRequest) => authApi.loginWithSms(body),
mutationFn: (body: SmsLoginRequest) =>
authApi.loginWithSms(withDeviceLanguage(body)),
onSuccess,
});
}
@@ -150,7 +164,8 @@ export function useRegister() {
const onSuccess = usePostAuthSetup();
return useMutation({
mutationFn: (body: RegisterRequest) => authApi.register(body),
mutationFn: (body: RegisterRequest) =>
authApi.register(withDeviceLanguage(body)),
onSuccess,
});
}
@@ -161,7 +176,8 @@ export function useSmsRegister() {
const onSuccess = usePostAuthSetup();
return useMutation({
mutationFn: (body: SmsRegisterRequest) => authApi.registerWithSms(body),
mutationFn: (body: SmsRegisterRequest) =>
authApi.registerWithSms(withDeviceLanguage(body)),
onSuccess,
});
}

View File

@@ -1,5 +1,7 @@
// ─── Response types ───
export type LanguagePreference = 'zh' | 'en';
export interface TokenResponse {
access_token: string;
refresh_token: string;
@@ -14,6 +16,7 @@ export interface UserInfo {
avatar_url: string | null;
subscription_type: string;
created_at: string;
language_preference?: LanguagePreference;
}
// ─── Request types ───
@@ -30,6 +33,7 @@ export interface RegisterRequest {
nickname: string;
email?: string;
agreed_to_terms: boolean;
language?: LanguagePreference;
}
export type SmsPurpose =
@@ -48,6 +52,7 @@ export interface SmsLoginRequest {
code: string;
agreed_to_terms: boolean;
nickname?: string;
language?: LanguagePreference;
}
export interface SmsRegisterRequest {
@@ -57,6 +62,7 @@ export interface SmsRegisterRequest {
nickname: string;
email?: string;
agreed_to_terms: boolean;
language?: LanguagePreference;
}
export interface ResetPasswordRequest {

View File

@@ -3,6 +3,7 @@ import { File, Paths } from 'expo-file-system';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import i18n from '@/i18n';
import type { WsConnectionState } from '@/core/ws/types';
import { conversationApi } from './api';
@@ -111,7 +112,7 @@ export function useCreateConversation() {
const now = Date.now();
const item: ConversationListItem = {
id: newConversation.id,
title: '岁月知己',
title: i18n.t('agentName', { ns: 'conversation' }),
avatarUrl: null,
latestMessagePreview: '',
latestMessageTime: now,
@@ -247,6 +248,14 @@ export function useRealtimeSession({
setAwaitingAssistantReply(false);
return;
}
/**
* 空文本 + 未完成时不能写成 `{text: '', isComplete: false}`
* UI 会渲染一只空 `StreamingBubbles`pulsing 气泡 + 光标),看上去与
* 「正在回复…」typing 气泡难以区分,且会一直挂在底部不消失。
*/
if (text.length === 0) {
return;
}
setStreamingMessage({ text, isComplete });
},
[],

View File

@@ -234,6 +234,12 @@ interface Resources {
};
signOut: 'Sign Out';
signingOut: 'Signing out...';
tier: {
free: 'Free';
pro: 'Pro';
pro_plus: 'Pro+';
test: 'Test';
};
userNamePlaceholder: 'User';
userTier: '{{tier}}';
};

View File

@@ -64,6 +64,12 @@
},
"signingOut": "Signing out...",
"signOut": "Sign Out",
"tier": {
"free": "Free",
"pro": "Pro",
"pro_plus": "Pro+",
"test": "Test"
},
"userNamePlaceholder": "User",
"userTier": "{{tier}}"
}

View File

@@ -64,6 +64,12 @@
},
"signingOut": "退出中...",
"signOut": "退出登录",
"tier": {
"free": "免费体验版",
"pro": "Pro 版",
"pro_plus": "Pro+ 版",
"test": "一分钱测试版"
},
"userNamePlaceholder": "用户",
"userTier": "{{tier}}"
}

View File

@@ -39,9 +39,32 @@ describe('message-split', () => {
});
it('splitStreamingSegments keeps empty tail after delimiter', () => {
/**
* 流式上下文(!isComplete下保留尾部空段让 UI 能在分隔符已出现、第二段尚未到字时
* 渲染「上一段已完成气泡 + 空流式气泡」。`StreamingBubbles` 在 isComplete=true 时
* 会过滤掉这只空尾段(见 conversation/[id].tsx 与对应注释),所以底部不会再永久挂一只
* 假装的「Replying…」气泡。
*/
expect(splitStreamingSegments('first [SPLIT]')).toEqual(['first', '']);
});
it('splitStreamingSegments handles lowercase / fullwidth split markers', () => {
expect(splitStreamingSegments('a [split] b')).toEqual(['a', 'b']);
expect(splitStreamingSegments('a【SPLIT】b')).toEqual(['a', 'b']);
expect(splitStreamingSegments('a [ SPLIT ] b')).toEqual(['a', 'b']);
});
it('splitMessageParts accepts spaced / lowercase delimiters', () => {
expect(splitMessageParts('first [ SPLIT ] second')).toEqual([
'first',
'second',
]);
expect(splitMessageParts('first [split] second')).toEqual([
'first',
'second',
]);
});
it('lastSegmentPreview uses last non-empty part', () => {
expect(lastSegmentPreview('a [SPLIT] b', 10)).toBe('b');
expect(lastSegmentPreview('hello', 3)).toBe('hel');