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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -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}}';
|
||||
};
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
},
|
||||
"signingOut": "Signing out...",
|
||||
"signOut": "Sign Out",
|
||||
"tier": {
|
||||
"free": "Free",
|
||||
"pro": "Pro",
|
||||
"pro_plus": "Pro+",
|
||||
"test": "Test"
|
||||
},
|
||||
"userNamePlaceholder": "User",
|
||||
"userTier": "{{tier}}"
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
},
|
||||
"signingOut": "退出中...",
|
||||
"signOut": "退出登录",
|
||||
"tier": {
|
||||
"free": "免费体验版",
|
||||
"pro": "Pro 版",
|
||||
"pro_plus": "Pro+ 版",
|
||||
"test": "一分钱测试版"
|
||||
},
|
||||
"userNamePlaceholder": "用户",
|
||||
"userTier": "{{tier}}"
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user