Skip to content

Commit dbaa645

Browse files
LessUpCopilot
andcommitted
refactor(arch): harden runtime ownership and cleanup
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7ae9b4a commit dbaa645

21 files changed

Lines changed: 839 additions & 404 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# PRD: 架构优化 — 消除透传层、集中Adapter定义、消除模块级状态
2+
3+
## 背景
4+
5+
通过架构分析发现以下摩擦点:
6+
7+
1. **纯透传层**`DigitalHumanViewer.tsx` 仅重新导出,无价值
8+
2. **Adapter分散** — 服务层 adapter 定义分散,难以看到完整依赖图
9+
3. **模块级状态**`dialogueOrchestrator``visionService` 的模块级状态导致测试泄漏
10+
11+
## 目标
12+
13+
1. 删除 `DigitalHumanViewer.tsx` 透传层
14+
2. 集中所有 adapter 定义到 `src/core/adapters.ts`
15+
3. 消除模块级状态,改为类实例或 store 管理
16+
17+
## 范围
18+
19+
### In Scope
20+
21+
- 删除透传文件并更新导入
22+
- 创建集中式 adapter 定义文件
23+
- 重构 dialogueOrchestrator 为类实例
24+
- 重构 visionService 为类实例
25+
26+
### Out of Scope
27+
28+
- ASRStateAdapter 拆分(工作量过大,单独任务)
29+
- 聊天流架构文档(低优先级)
30+
31+
## 验收标准
32+
33+
- [ ] 所有导入 DigitalHumanViewer 的地方已更新
34+
- [ ] 所有 adapter 定义集中在 `src/core/adapters.ts`
35+
- [ ] dialogueOrchestrator 无模块级状态
36+
- [ ] visionService 无模块级状态
37+
- [ ] 现有测试通过
38+
- [ ] 类型检查通过
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"id": "adapter",
3+
"name": "adapter",
4+
"title": "架构优化:消除透传层、集中Adapter定义、消除模块级状态",
5+
"description": "",
6+
"status": "in_progress",
7+
"dev_type": null,
8+
"scope": null,
9+
"package": null,
10+
"priority": "P2",
11+
"creator": "shane",
12+
"assignee": "shane",
13+
"createdAt": "2026-05-21",
14+
"completedAt": null,
15+
"branch": null,
16+
"base_branch": "master",
17+
"worktree_path": null,
18+
"commit": null,
19+
"pr_url": null,
20+
"subtasks": [],
21+
"children": [],
22+
"parent": null,
23+
"relatedFiles": [],
24+
"notes": "",
25+
"meta": {}
26+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# 架构优化:消除透传层、集中Adapter定义、消除模块级状态
2+
3+
## 变更类型
4+
5+
- refactor: 架构优化
6+
7+
## 变更内容
8+
9+
### 1. 删除透传层
10+
11+
- 删除 `src/components/DigitalHumanViewer.tsx`(仅重新导出,无价值)
12+
- 更新所有导入路径直接使用 `@/components/viewer`
13+
14+
### 2. 集中 Adapter 定义
15+
16+
- 新建 `src/core/adapters.ts`,集中所有服务层与状态层的适配器接口和工厂函数
17+
- 更新 `createServices.ts` 使用集中式 adapters
18+
- 更新 `audioService.ts` 从 adapters.ts 导入接口
19+
- 更新 `DigitalHumanEngine.ts` 从 adapters.ts 导入接口
20+
21+
### 3. 消除模块级状态
22+
23+
- 重构 `dialogueOrchestrator.ts``DialogueOrchestrator`
24+
- 模块级状态改为类实例状态,避免测试间泄漏
25+
- 保留向后兼容的函数导出(标记为 deprecated)
26+
27+
### 4. 收紧运行时所有权与清理
28+
29+
- `ServicesProvider` 统一负责共享 `engine` / `tts` / `asr` 的释放
30+
- `useAdvancedDigitalHumanController` 不再在页面卸载时误销毁应用级单例服务
31+
- `useChatStream` 在卸载和会话切换时主动中止未完成的对话轮次,避免悬挂流式回调
32+
33+
### 5. 修复状态透传与适配器安全性
34+
35+
- `AdvancedDigitalHumanPage` 改为从 store 读取真实 `isMuted`,修复聊天流始终未静音的问题
36+
- `src/core/adapters.ts` 改为静态 ESM 导入,移除浏览器侧运行时 `require()` 调用
37+
- 新增回归测试覆盖 Provider 清理、控制器生命周期、聊天流卸载清理、静音状态透传
38+
39+
## 影响范围
40+
41+
- `src/components/DigitalHumanViewer.tsx`(已删除)
42+
- `src/pages/AdvancedDigitalHumanPage.tsx`
43+
- `src/pages/DigitalHumanPage.tsx`
44+
- `src/__tests__/digitalHuman.test.tsx`
45+
- `src/core/adapters.ts`(新增)
46+
- `src/core/createServices.ts`
47+
- `src/core/audio/audioService.ts`
48+
- `src/core/avatar/DigitalHumanEngine.ts`
49+
- `src/core/dialogue/dialogueOrchestrator.ts`
50+
- `src/core/ServicesProvider.tsx`
51+
- `src/hooks/useAdvancedDigitalHumanController.ts`
52+
- `src/hooks/useChatStream.ts`
53+
- `src/__tests__/AdvancedDigitalHumanPage.test.tsx`(新增)
54+
- `src/__tests__/ServicesProvider.test.tsx`(新增)
55+
- `src/__tests__/useAdvancedDigitalHumanController.test.tsx`
56+
- `src/__tests__/useChatStream.test.tsx`
57+
58+
## 验证
59+
60+
- ✅ TypeScript 类型检查通过
61+
- ✅ ESLint 检查通过
62+
- ✅ 168 个测试全部通过
63+
64+
## 参考
65+
66+
- 架构分析发现的问题:透传层、Adapter分散、模块级状态泄漏
67+
- 解决方案:删除透传、集中定义、类实例化
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { render } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import AdvancedDigitalHumanPage from '@/pages/AdvancedDigitalHumanPage';
4+
import { useDigitalHumanStore } from '@/store/digitalHumanStore';
5+
6+
const useChatStreamMock = vi.fn();
7+
8+
vi.mock('@/components/viewer', () => ({
9+
DigitalHumanViewer: () => <div data-testid="viewer" />,
10+
}));
11+
12+
vi.mock('@/components/TopHUD', () => ({
13+
default: () => <div data-testid="top-hud" />,
14+
}));
15+
16+
vi.mock('@/components/SettingsDrawer', () => ({
17+
default: () => <div data-testid="settings-drawer" />,
18+
}));
19+
20+
vi.mock('@/components/ChatDock', () => ({
21+
default: () => <div data-testid="chat-dock" />,
22+
}));
23+
24+
vi.mock('@/hooks/useConnectionHealth', () => ({
25+
useConnectionHealth: () => ({
26+
reconnect: vi.fn(),
27+
}),
28+
}));
29+
30+
vi.mock('@/hooks/useAdvancedDigitalHumanController', () => ({
31+
useAdvancedDigitalHumanController: () => ({
32+
activeTab: 'basic',
33+
autoRotate: false,
34+
closeSettings: vi.fn(),
35+
handleBehaviorChange: vi.fn(),
36+
handleEmotionChange: vi.fn(),
37+
handleExpressionChange: vi.fn(),
38+
handleHeadMotion: vi.fn(),
39+
handleModelLoad: vi.fn(),
40+
handleNewSession: vi.fn(),
41+
handlePlayPause: vi.fn(),
42+
handleReset: vi.fn(),
43+
handleToggleRecording: vi.fn(),
44+
handleVoiceCommand: vi.fn(),
45+
setActiveTab: vi.fn(),
46+
showSettings: false,
47+
toggleMute: vi.fn(),
48+
toggleSettings: vi.fn(),
49+
toggleAutoRotate: vi.fn(),
50+
setConnectionStatus: vi.fn(),
51+
setError: vi.fn(),
52+
clearError: vi.fn(),
53+
sessionId: 'session_test',
54+
}),
55+
}));
56+
57+
vi.mock('@/hooks/useChatStream', () => ({
58+
useChatStream: (...args: unknown[]) => useChatStreamMock(...args),
59+
}));
60+
61+
describe('AdvancedDigitalHumanPage', () => {
62+
beforeEach(() => {
63+
useChatStreamMock.mockReset();
64+
useChatStreamMock.mockReturnValue({
65+
chatInput: '',
66+
setChatInput: vi.fn(),
67+
isChatLoading: false,
68+
handleChatSend: vi.fn(),
69+
});
70+
71+
useDigitalHumanStore.setState({
72+
isMuted: true,
73+
});
74+
});
75+
76+
it('passes the muted state from the store into useChatStream', () => {
77+
render(<AdvancedDigitalHumanPage />);
78+
79+
expect(useChatStreamMock).toHaveBeenCalledWith(
80+
expect.objectContaining({
81+
isMuted: true,
82+
}),
83+
);
84+
});
85+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { render } from '@testing-library/react';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
const createServicesMock = vi.fn();
5+
const disposeMock = vi.fn();
6+
const resetMock = vi.fn();
7+
8+
vi.mock('@/core/createServices', () => ({
9+
createServices: () => createServicesMock(),
10+
}));
11+
12+
describe('ServicesProvider', () => {
13+
afterEach(() => {
14+
createServicesMock.mockReset();
15+
disposeMock.mockReset();
16+
resetMock.mockReset();
17+
});
18+
19+
it('disposes provider-owned services on unmount', async () => {
20+
createServicesMock.mockReturnValue({
21+
engine: {
22+
dispose: disposeMock,
23+
},
24+
tts: {
25+
dispose: disposeMock,
26+
},
27+
asr: {
28+
dispose: disposeMock,
29+
},
30+
dialogue: {
31+
reset: resetMock,
32+
},
33+
});
34+
35+
const { ServicesProvider } = await import('@/core/ServicesProvider');
36+
const { unmount } = render(
37+
<ServicesProvider>
38+
<div>child</div>
39+
</ServicesProvider>,
40+
);
41+
42+
unmount();
43+
44+
expect(resetMock).toHaveBeenCalledTimes(1);
45+
expect(disposeMock).toHaveBeenCalledTimes(3);
46+
});
47+
});

src/__tests__/digitalHuman.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3-
import DigitalHumanViewer from '../components/DigitalHumanViewer';
3+
import { DigitalHumanViewer } from '../components/viewer';
44
import ControlPanel from '../components/ControlPanel';
55
import { useDigitalHumanStore } from '../store/digitalHumanStore';
66
import { useChatSessionStore } from '../store/chatSessionStore';

src/__tests__/useAdvancedDigitalHumanController.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,11 @@ describe('useAdvancedDigitalHumanController', () => {
242242
expect(useSystemStore.getState().error).toBeNull();
243243
});
244244

245-
it('disposes the avatar engine on unmount', () => {
245+
it('does not dispose the avatar engine on unmount', () => {
246246
const { unmount } = renderHook(() => useAdvancedDigitalHumanController());
247247

248248
unmount();
249249

250-
expect(mocks.digitalHumanDisposeMock).toHaveBeenCalledTimes(1);
250+
expect(mocks.digitalHumanDisposeMock).not.toHaveBeenCalled();
251251
});
252252
});

src/__tests__/useChatStream.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useChatSessionStore } from '../store/chatSessionStore';
66
import { useSystemStore } from '../store/systemStore';
77

88
const runDialogueTurnStreamMock = vi.fn();
9+
const abortPendingTurnMock = vi.fn();
910

1011
vi.mock('@/core/services', () => ({
1112
useTTS: () => ({ speak: vi.fn() }),
@@ -14,11 +15,13 @@ vi.mock('@/core/services', () => ({
1415

1516
vi.mock('../core/dialogue/dialogueOrchestrator', () => ({
1617
runDialogueTurnStream: (...args: unknown[]) => runDialogueTurnStreamMock(...args),
18+
abortPendingTurn: () => abortPendingTurnMock(),
1719
}));
1820

1921
describe('useChatStream', () => {
2022
beforeEach(() => {
2123
runDialogueTurnStreamMock.mockReset();
24+
abortPendingTurnMock.mockReset();
2225
useDigitalHumanStore.setState({
2326
currentBehavior: 'idle',
2427
});
@@ -109,4 +112,20 @@ describe('useChatStream', () => {
109112
expect(useSystemStore.getState().chatPerformance.firstTokenMs).toBeNull();
110113
expect(useSystemStore.getState().chatPerformance.responseCompleteMs).not.toBeNull();
111114
});
115+
116+
it('aborts the pending dialogue turn on unmount', () => {
117+
const { unmount } = renderHook(() =>
118+
useChatStream({
119+
sessionId: 'session_test',
120+
isMuted: false,
121+
onConnectionChange: vi.fn(),
122+
onClearError: vi.fn(),
123+
onError: vi.fn(),
124+
}),
125+
);
126+
127+
unmount();
128+
129+
expect(abortPendingTurnMock).toHaveBeenCalledTimes(1);
130+
});
112131
});

0 commit comments

Comments
 (0)