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

@@ -24,6 +24,16 @@ CODE_LENGTH = 6
CODE_EXPIRE_MINUTES = 5
RATE_LIMIT_SECONDS = 60
_VALID_LANGUAGES = {"zh", "en"}
def _normalize_language(lang: str | None) -> str:
"""Normalize device language token; default to zh on missing/unknown."""
if not lang:
return "zh"
s = str(lang).strip().lower()
return s if s in _VALID_LANGUAGES else "zh"
class AuthError(Exception):
def __init__(self, message: str, code: str = "AUTH_ERROR"):
@@ -120,6 +130,7 @@ class AuthService:
password: str,
nickname: str,
email: str | None = None,
language: str | None = None,
) -> dict:
"""Register new user. Returns {user, access_token, refresh_token}."""
if await repo.get_user_by_phone(phone, self._db):
@@ -137,6 +148,7 @@ class AuthService:
nickname=nickname,
subscription_type="free",
created_at=datetime.now(timezone.utc),
language_preference=_normalize_language(language),
)
await repo.create_user(user, self._db)
tokens = await self._issue_tokens(user_id)
@@ -206,6 +218,7 @@ class AuthService:
code: str,
device_info: str = "",
nickname: str | None = None,
language: str | None = None,
) -> dict:
"""SMS login (auto-register if new). Returns {user, access_token, refresh_token, is_new_user}."""
success = False
@@ -219,7 +232,10 @@ class AuthService:
raise AuthError(message, "INVALID_SMS_CODE")
return await self._sms_login_after_code_verified(
phone, device_info=device_info, nickname=nickname
phone,
device_info=device_info,
nickname=nickname,
language=language,
)
async def _sms_login_after_code_verified(
@@ -228,8 +244,12 @@ class AuthService:
*,
device_info: str = "",
nickname: str | None = None,
language: str | None = None,
) -> dict:
"""SMS 已校验通过后:查找或创建用户并签发令牌。"""
"""SMS 已校验通过后:查找或创建用户并签发令牌。
``language`` 仅在「新用户」分支下写入;命中已有用户时不覆盖偏好。
"""
user = await repo.get_user_by_phone(phone, self._db)
is_new_user = user is None
@@ -242,6 +262,7 @@ class AuthService:
nickname=(nickname or "").strip(),
subscription_type="free",
created_at=datetime.now(timezone.utc),
language_preference=_normalize_language(language),
)
await repo.create_user(user, self._db)
@@ -257,10 +278,14 @@ class AuthService:
phone: str,
device_info: str = "",
nickname: str | None = None,
language: str | None = None,
) -> dict:
"""跳过短信校验的登录/自动注册(仅由 mock 路由在配置允许时调用)。"""
return await self._sms_login_after_code_verified(
phone, device_info=device_info, nickname=nickname
phone,
device_info=device_info,
nickname=nickname,
language=language,
)
async def register_with_sms(
@@ -271,6 +296,7 @@ class AuthService:
nickname: str,
email: str | None = None,
device_info: str = "",
language: str | None = None,
) -> dict:
"""SMS register. Returns {user, access_token, refresh_token}."""
success, message = await self._verify_sms_code(phone, code, "register")
@@ -292,6 +318,7 @@ class AuthService:
nickname=nickname,
subscription_type="free",
created_at=datetime.now(timezone.utc),
language_preference=_normalize_language(language),
)
await repo.create_user(user, self._db)
tokens = await self._issue_tokens(user_id, device_info)