Skip to content

Commit cdeea01

Browse files
author
shijiashuai
committed
feat: 收口前端状态边界并增强聊天观测
拆分 chat/session 与 system 状态域,并把高级页控制逻辑从页面组件中抽离,降低页面和运行时耦合。把 transport 自动选择、协议展示和聊天时延指标串起来,便于后续灰度验证与性能调优。
1 parent 2507196 commit cdeea01

22 files changed

+1222
-417
lines changed

docs/architecture.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,22 @@ UI 层尽量“只读 store + 调用高层 action”,避免直接操作底层
116116
- 默认保留 SSE,WebSocket 作为增强模式接入,不把页面逻辑和具体传输协议绑定。
117117
- 给 transport 增加连接态、重连策略、超时和 server capability 探测。
118118
- 支持通过 `VITE_CHAT_TRANSPORT=http|sse|websocket` 切换策略,便于灰度验证。
119+
- `auto` 模式下新增运行时 probe:优先尝试 WebSocket 握手,失败则回落到 SSE,再退化到 HTTP;当前结果同步到 `systemStore.chatTransportMode`
119120

120121
### Phase 3: 拆分状态域
121122

122123
- 将当前 store 按 `session/chat``avatar/runtime``system/connection` 三个域拆分。
123124
- 减少 3D 渲染状态和聊天 UI 状态的相互影响,降低页面级组件重渲染。
124125
- 将可序列化状态和不可序列化运行时对象彻底分开。
126+
- 当前已完成第一步:`sessionId/chatHistory` 与消息增删改逻辑已迁移到独立 `chatSessionStore``digitalHumanStore` 保留跨域协调动作 `initSession`
127+
- 当前已完成第二步:`error/isLoading/connectionStatus/isConnected` 已迁移到独立 `systemStore`,连接健康检查、聊天错误反馈、HUD 与输入区状态展示均已接入新域。
125128

126129
### Phase 4: 体验与性能优化
127130

128131
- Viewer 根据设备能力动态调节粒子数、阴影、DPR 和后处理开关。
129132
- 为聊天区、设置面板、模型加载过程补充 skeleton/placeholder,减少跳变感。
130133
- 增加前端性能埋点:首屏加载、模型加载耗时、首 token 时间、完整回复时间。
134+
- 当前已完成首轮聊天指标采集:最近一次对话的 `firstTokenMs``responseCompleteMs` 已记录到 `systemStore.chatPerformance`,并在 `TopHUD` 中展示。
131135

132136
## 7. 本轮已落地优化
133137

@@ -137,3 +141,8 @@ UI 层尽量“只读 store + 调用高层 action”,避免直接操作底层
137141
- 聊天 hook 改为 selector 订阅,减少因 store 全量订阅带来的额外重渲染。
138142
- 新增统一 `chat transport` 抽象,并让 orchestrator 改为依赖 transport 而不是直接依赖具体协议实现。
139143
- 修复流式降级到 fallback 时丢失 `connectionStatus/error` 的问题。
144+
- 抽离独立 `chatSessionStore`,将聊天消息与会话 ID 从 `digitalHumanStore` 中分域,降低聊天 UI 对主运行时 store 的耦合。
145+
- 抽离独立 `systemStore`,将连接状态、加载态和错误态从 `digitalHumanStore` 中分域,进一步收窄主 store 职责边界。
146+
- 新增 chat transport capability probe,并在健康检查时刷新自动选择结果,为后续 WebSocket 灰度与能力探测打基础。
147+
- 抽离 `useAdvancedDigitalHumanController`,将高级页面的业务控制逻辑从布局组件中移出,降低页面组件复杂度并为后续 controller 测试留出边界。
148+
- 增加聊天性能快照采集与 HUD 可视化,可直接观察最近一次请求的首 token 时间和完整响应时间。

src/__tests__/TopHUD.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import TopHUD from '../components/TopHUD';
4+
import { useDigitalHumanStore } from '../store/digitalHumanStore';
5+
import { useChatSessionStore } from '../store/chatSessionStore';
6+
import { useSystemStore } from '../store/systemStore';
7+
8+
describe('TopHUD', () => {
9+
beforeEach(() => {
10+
useDigitalHumanStore.setState({
11+
currentBehavior: 'idle',
12+
});
13+
useChatSessionStore.setState({
14+
sessionId: 'session_test',
15+
chatHistory: [],
16+
});
17+
useSystemStore.setState({
18+
isConnected: true,
19+
connectionStatus: 'connected',
20+
isLoading: false,
21+
error: null,
22+
lastErrorTime: null,
23+
chatTransportMode: 'sse',
24+
chatPerformance: {
25+
status: 'idle',
26+
firstTokenMs: null,
27+
responseCompleteMs: null,
28+
startedAt: null,
29+
completedAt: null,
30+
},
31+
});
32+
});
33+
34+
it('shows the current transport mode from system store', () => {
35+
render(<TopHUD onToggleSettings={vi.fn()} onReconnect={vi.fn()} onNewSession={vi.fn()} />);
36+
37+
expect(screen.getByText('协议:')).toBeInTheDocument();
38+
expect(screen.getByText('SSE')).toBeInTheDocument();
39+
});
40+
41+
it('maps websocket mode to a user-facing label', () => {
42+
useSystemStore.setState({ chatTransportMode: 'websocket' });
43+
44+
render(<TopHUD onToggleSettings={vi.fn()} onReconnect={vi.fn()} onNewSession={vi.fn()} />);
45+
46+
expect(screen.getByText('WebSocket')).toBeInTheDocument();
47+
});
48+
49+
it('shows latest chat timing metrics when available', () => {
50+
useSystemStore.setState({
51+
chatPerformance: {
52+
status: 'completed',
53+
firstTokenMs: 120,
54+
responseCompleteMs: 560,
55+
startedAt: 1000,
56+
completedAt: 1560,
57+
},
58+
});
59+
60+
render(<TopHUD onToggleSettings={vi.fn()} onReconnect={vi.fn()} onNewSession={vi.fn()} />);
61+
62+
expect(screen.getByText('120ms')).toBeInTheDocument();
63+
expect(screen.getByText('560ms')).toBeInTheDocument();
64+
});
65+
});

src/__tests__/chatTransport.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ const streamUserInputMock = vi.fn();
66
const wsConnectMock = vi.fn();
77
const wsSendMock = vi.fn();
88
const wsDisconnectMock = vi.fn();
9+
const probeWebSocketEndpointMock = vi.fn();
910

1011
vi.mock('../core/dialogue/dialogueService', () => ({
1112
sendUserInput: (...args: unknown[]) => sendUserInputMock(...args),
1213
streamUserInput: (...args: unknown[]) => streamUserInputMock(...args),
1314
}));
1415

1516
vi.mock('../core/dialogue/wsClient', () => ({
17+
probeWebSocketEndpoint: (...args: unknown[]) => probeWebSocketEndpointMock(...args),
1618
MetaHumanWSClient: class {
1719
connect = (...args: unknown[]) => wsConnectMock(...args);
1820
send = (...args: unknown[]) => wsSendMock(...args);
@@ -27,6 +29,8 @@ describe('chatTransport', () => {
2729
wsConnectMock.mockReset();
2830
wsSendMock.mockReset();
2931
wsDisconnectMock.mockReset();
32+
probeWebSocketEndpointMock.mockReset();
33+
probeWebSocketEndpointMock.mockResolvedValue(false);
3034
vi.resetModules();
3135
});
3236

@@ -120,4 +124,28 @@ describe('chatTransport', () => {
120124
});
121125
}
122126
});
127+
128+
it('resolves auto mode to websocket after a successful probe', async () => {
129+
probeWebSocketEndpointMock.mockResolvedValue(true);
130+
131+
const { getDefaultChatTransport, resetChatTransportProbeCache, resolveChatTransportMode } =
132+
await import('../core/dialogue/chatTransport');
133+
134+
resetChatTransportProbeCache();
135+
136+
await expect(resolveChatTransportMode()).resolves.toBe('websocket');
137+
expect(getDefaultChatTransport().mode).toBe('websocket');
138+
});
139+
140+
it('keeps auto mode on sse when websocket probe fails', async () => {
141+
probeWebSocketEndpointMock.mockResolvedValue(false);
142+
143+
const { getDefaultChatTransport, resetChatTransportProbeCache, resolveChatTransportMode } =
144+
await import('../core/dialogue/chatTransport');
145+
146+
resetChatTransportProbeCache();
147+
148+
await expect(resolveChatTransportMode()).resolves.toBe('sse');
149+
expect(getDefaultChatTransport().mode).toBe('sse');
150+
});
123151
});

src/__tests__/digitalHuman.test.tsx

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
33
import DigitalHumanViewer from '../components/DigitalHumanViewer';
44
import ControlPanel from '../components/ControlPanel';
55
import { useDigitalHumanStore } from '../store/digitalHumanStore';
6+
import { useChatSessionStore } from '../store/chatSessionStore';
7+
import { useSystemStore } from '../store/systemStore';
68
import { TTSService, ASRService } from '../core/audio/audioService';
79
import { handleDialogueResponse } from '../core/dialogue/dialogueOrchestrator';
810
import React from 'react';
@@ -246,9 +248,17 @@ describe('DigitalHumanStore', () => {
246248
currentExpression: 'neutral',
247249
expressionIntensity: 0.8,
248250
currentBehavior: 'idle',
251+
});
252+
useChatSessionStore.setState({
253+
sessionId: 'test-session',
249254
chatHistory: [],
255+
});
256+
useSystemStore.setState({
250257
error: null,
251258
lastErrorTime: null,
259+
connectionStatus: 'connected',
260+
isConnected: true,
261+
isLoading: false,
252262
});
253263
});
254264

@@ -291,20 +301,20 @@ describe('DigitalHumanStore', () => {
291301
});
292302

293303
it('does not add empty chat messages', () => {
294-
const { addChatMessage } = useDigitalHumanStore.getState();
304+
const { addChatMessage } = useChatSessionStore.getState();
295305
addChatMessage('user', ' ');
296-
expect(useDigitalHumanStore.getState().chatHistory).toHaveLength(0);
306+
expect(useChatSessionStore.getState().chatHistory).toHaveLength(0);
297307
});
298308

299309
it('generates unique chat message ids under the same timestamp', () => {
300310
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
301311

302312
try {
303-
const { addChatMessage } = useDigitalHumanStore.getState();
313+
const { addChatMessage } = useChatSessionStore.getState();
304314
addChatMessage('user', '第一条消息');
305315
addChatMessage('assistant', '第二条消息');
306316

307-
const [firstMessage, secondMessage] = useDigitalHumanStore.getState().chatHistory;
317+
const [firstMessage, secondMessage] = useChatSessionStore.getState().chatHistory;
308318

309319
expect(firstMessage.id).not.toBe(secondMessage.id);
310320
expect(firstMessage.timestamp).toBe(1000);
@@ -477,7 +487,7 @@ describe('ASRService', () => {
477487
return useDigitalHumanStore.getState().isMuted;
478488
},
479489
get sessionId() {
480-
return useDigitalHumanStore.getState().sessionId;
490+
return useChatSessionStore.getState().sessionId;
481491
},
482492
get currentBehavior() {
483493
return useDigitalHumanStore.getState().currentBehavior;
@@ -518,8 +528,8 @@ describe('ASRService', () => {
518528

519529
describe('Dialogue orchestration', () => {
520530
beforeEach(() => {
521-
useDigitalHumanStore.getState().clearChatHistory();
522-
useDigitalHumanStore.getState().clearError();
531+
useChatSessionStore.getState().clearChatHistory();
532+
useSystemStore.getState().clearError();
523533
useDigitalHumanStore.getState().setMuted(false);
524534
useDigitalHumanStore.getState().setBehavior('idle');
525535
useDigitalHumanStore.getState().setSpeaking(false);
@@ -548,32 +558,34 @@ describe('Dialogue orchestration', () => {
548558

549559
describe('Error throttle and session lifecycle', () => {
550560
beforeEach(() => {
551-
useDigitalHumanStore.setState({
561+
useSystemStore.setState({
552562
error: null,
553563
lastErrorTime: null,
554-
sessionId: 'test-session',
555-
chatHistory: [],
556564
connectionStatus: 'connected',
557565
isConnected: true,
558566
isLoading: false,
559567
});
568+
useChatSessionStore.setState({
569+
sessionId: 'test-session',
570+
chatHistory: [],
571+
});
560572
});
561573

562574
it('throttles identical error messages within 2 seconds', () => {
563575
const nowSpy = vi.spyOn(Date, 'now');
564576

565577
try {
566578
nowSpy.mockReturnValue(1000);
567-
useDigitalHumanStore.getState().setError('网络错误');
568-
expect(useDigitalHumanStore.getState().error).toBe('网络错误');
579+
useSystemStore.getState().setError('网络错误');
580+
expect(useSystemStore.getState().error).toBe('网络错误');
569581

570582
nowSpy.mockReturnValue(2000);
571-
useDigitalHumanStore.getState().setError('网络错误');
572-
expect(useDigitalHumanStore.getState().lastErrorTime).toBe(1000);
583+
useSystemStore.getState().setError('网络错误');
584+
expect(useSystemStore.getState().lastErrorTime).toBe(1000);
573585

574586
nowSpy.mockReturnValue(3500);
575-
useDigitalHumanStore.getState().setError('网络错误');
576-
expect(useDigitalHumanStore.getState().lastErrorTime).toBe(3500);
587+
useSystemStore.getState().setError('网络错误');
588+
expect(useSystemStore.getState().lastErrorTime).toBe(3500);
577589
} finally {
578590
nowSpy.mockRestore();
579591
}
@@ -584,35 +596,36 @@ describe('Error throttle and session lifecycle', () => {
584596

585597
try {
586598
nowSpy.mockReturnValue(1000);
587-
useDigitalHumanStore.getState().setError('错误A');
588-
expect(useDigitalHumanStore.getState().error).toBe('错误A');
599+
useSystemStore.getState().setError('错误A');
600+
expect(useSystemStore.getState().error).toBe('错误A');
589601

590602
nowSpy.mockReturnValue(1500);
591-
useDigitalHumanStore.getState().setError('错误B');
592-
expect(useDigitalHumanStore.getState().error).toBe('错误B');
593-
expect(useDigitalHumanStore.getState().lastErrorTime).toBe(1500);
603+
useSystemStore.getState().setError('错误B');
604+
expect(useSystemStore.getState().error).toBe('错误B');
605+
expect(useSystemStore.getState().lastErrorTime).toBe(1500);
594606
} finally {
595607
nowSpy.mockRestore();
596608
}
597609
});
598610

599611
it('initSession resets error, loading and connection status', () => {
600-
useDigitalHumanStore.setState({
612+
useSystemStore.setState({
601613
error: '旧错误',
602614
lastErrorTime: 999,
603615
isLoading: true,
604616
connectionStatus: 'error',
605617
isConnected: false,
606618
});
607619

608-
const oldSessionId = useDigitalHumanStore.getState().sessionId;
620+
const oldSessionId = useChatSessionStore.getState().sessionId;
609621

610622
useDigitalHumanStore.getState().initSession();
611623

612-
const state = useDigitalHumanStore.getState();
624+
const state = useSystemStore.getState();
625+
const sessionState = useChatSessionStore.getState();
613626

614-
expect(state.sessionId).not.toBe(oldSessionId);
615-
expect(state.chatHistory).toHaveLength(0);
627+
expect(sessionState.sessionId).not.toBe(oldSessionId);
628+
expect(sessionState.chatHistory).toHaveLength(0);
616629
expect(state.error).toBeNull();
617630
expect(state.lastErrorTime).toBeNull();
618631
expect(state.isLoading).toBe(false);

0 commit comments

Comments
 (0)