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

@@ -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 });
},
[],