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

@@ -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');