Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions changelog/2026-05-22-dialogue-runtime-deepening.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 架构深化:对话运行时下沉到服务容器

## 变更摘要

1. **移除应用路径对全局对话单例的依赖**
- `Services` 新增 `dialogue: DialogueOrchestrator`
- 新增 `useDialogue()` Hook
- `useChatStream`、`useSessionManager`、`ASRService` 改为使用 Provider 作用域内的对话运行时

2. **修复 `DigitalHumanEngine` 事件接口不一致**
- 非法 expression / emotion / behavior 输入现在统一归一化为 `neutral` 或 `idle`
- 事件 payload 与实际写入 store 的值保持一致,避免下游监听者收到不可能状态

3. **收紧 UI store 订阅面**
- `ControlPanel` 改为使用精确 selector
- `DigitalHumanPage` 改为按字段订阅,减少无关状态变化引发的重渲染

4. **补齐回归测试**
- 新增 `src/__tests__/audioService.test.ts`
- 新增 `src/__tests__/ControlPanel.test.tsx`
- 新增 `src/__tests__/useSessionManager.test.tsx`
- 扩展服务层 / Hook / Engine 相关测试

## 影响范围

- `src/core/services*.ts`
- `src/core/audio/audioService.ts`
- `src/core/avatar/DigitalHumanEngine.ts`
- `src/hooks/useChatStream.ts`
- `src/hooks/useSessionManager.ts`
- `src/components/ControlPanel.tsx`
- `src/pages/DigitalHumanPage.tsx`
- `src/__tests__/`

## 测试结果

- TypeScript 类型检查:通过
- ESLint:通过
- Vitest:175 passed
- Build:通过
50 changes: 50 additions & 0 deletions src/__tests__/ControlPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Profiler } from 'react';
import { act, render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ControlPanel from '@/components/ControlPanel';
import { useDigitalHumanStore } from '@/store/digitalHumanStore';
import { useSystemStore } from '@/store/systemStore';

describe('ControlPanel', () => {
beforeEach(() => {
useDigitalHumanStore.getState().reset();
useDigitalHumanStore.setState({
isSpeaking: false,
currentBehavior: 'idle',
currentEmotion: 'neutral',
});
useSystemStore.setState({
connectionStatus: 'connected',
isConnected: true,
});
});

it('does not rerender for unrelated digital human store updates', async () => {
const onRender = vi.fn();

render(
<Profiler id="control-panel" onRender={onRender}>
<ControlPanel
isPlaying={false}
isRecording={false}
isMuted={false}
autoRotate={false}
onPlayPause={vi.fn()}
onReset={vi.fn()}
onToggleRecording={vi.fn()}
onToggleMute={vi.fn()}
onToggleAutoRotate={vi.fn()}
onVoiceCommand={vi.fn()}
/>
</Profiler>,
);

expect(onRender).toHaveBeenCalledTimes(1);

await act(async () => {
useDigitalHumanStore.getState().setEmotion('happy');
});

expect(onRender).toHaveBeenCalledTimes(1);
});
});
39 changes: 39 additions & 0 deletions src/__tests__/ServicesProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,43 @@ describe('ServicesProvider', () => {
expect(resetMock).toHaveBeenCalledTimes(1);
expect(disposeMock).toHaveBeenCalledTimes(3);
});

it('exposes provider-owned dialogue runtime through service hooks', async () => {
const dialogue = {
abortPendingTurn: vi.fn(),
isTurnPending: vi.fn(() => false),
reset: resetMock,
runDialogueTurn: vi.fn(),
runDialogueTurnStream: vi.fn(),
};

createServicesMock.mockReturnValue({
engine: {
dispose: disposeMock,
},
tts: {
dispose: disposeMock,
},
asr: {
dispose: disposeMock,
},
dialogue,
});

const { ServicesProvider, useDialogue } = await import('@/core/services');
const captured = { current: null as unknown };

function Consumer() {
captured.current = useDialogue();
return null;
}

render(
<ServicesProvider>
<Consumer />
</ServicesProvider>,
);

expect(captured.current).toBe(dialogue);
});
});
55 changes: 55 additions & 0 deletions src/__tests__/audioService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';

const moduleRunDialogueTurnMock = vi.fn((_: string, _options: unknown) => {
throw new Error('ASRService should use injected dialogue runtime');
});

vi.mock('@/core/dialogue/dialogueOrchestrator', () => ({
runDialogueTurn: (text: string, options: unknown) => moduleRunDialogueTurnMock(text, options),
}));

import { ASRService } from '@/core/audio/audioService';

describe('ASRService dialogue runtime', () => {
it('routes backend dialogue through injected runtime', async () => {
const dialogue = {
runDialogueTurn: vi.fn().mockResolvedValue(undefined),
};
const state = {
setRecording: vi.fn(),
setBehavior: vi.fn(),
setSpeaking: vi.fn(),
setError: vi.fn(),
setEmotion: vi.fn(),
setExpression: vi.fn(),
setAnimation: vi.fn(),
play: vi.fn(),
pause: vi.fn(),
reset: vi.fn(),
setMuted: vi.fn(),
isMuted: false,
sessionId: 'session_test',
currentBehavior: 'idle',
addChatMessage: vi.fn(),
};
const tts = {
speak: vi.fn().mockResolvedValue(undefined),
};

const TestableASRService = ASRService as unknown as new (...args: any[]) => ASRService;
const asr = new TestableASRService({}, state, tts, dialogue);

await (
asr as unknown as { sendToDialogueService(text: string): Promise<void> }
).sendToDialogueService('你好');

expect(dialogue.runDialogueTurn).toHaveBeenCalledWith(
'你好',
expect.objectContaining({
sessionId: 'session_test',
isMuted: false,
}),
);
expect(moduleRunDialogueTurnMock).not.toHaveBeenCalled();
});
});
10 changes: 7 additions & 3 deletions src/__tests__/digitalHuman.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,9 @@ describe('ASRService', () => {
let asrService: ASRService;
let localTts: TTSService;
let mockSpeechRecognition: any;
const mockDialogue = {
runDialogueTurn: vi.fn(),
};

beforeEach(() => {
// Create a proper constructor function for SpeechRecognition
Expand Down Expand Up @@ -472,6 +475,7 @@ describe('ASRService', () => {
let mockState: any;

beforeEach(() => {
mockDialogue.runDialogueTurn.mockReset();
mockState = {
setRecording: vi.fn(),
setBehavior: vi.fn(),
Expand All @@ -498,19 +502,19 @@ describe('ASRService', () => {
});

it('initializes correctly when supported', () => {
asrService = new ASRService({}, mockState, localTts);
asrService = new ASRService({}, mockState, localTts, mockDialogue);
expect(asrService).toBeDefined();
});

it('starts recognition', () => {
asrService = new ASRService({}, mockState, localTts);
asrService = new ASRService({}, mockState, localTts, mockDialogue);
asrService.start();
// Since we can't directly access the mock, we verify the service is created
expect(asrService).toBeDefined();
});

it('stops recognition', () => {
asrService = new ASRService({}, mockState, localTts);
asrService = new ASRService({}, mockState, localTts, mockDialogue);
asrService.stop();
// Verify no errors are thrown
expect(asrService).toBeDefined();
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/digitalHumanEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ describe('DigitalHumanEngine', () => {
expect(handler).toHaveBeenCalledWith({ type: 'expression:change', value: 'smile' });
});

it('emits normalized expression:change event for invalid expressions', () => {
const handler = vi.fn();
engine.on('expression:change', handler);

engine.setExpression('invalid_expr');

expect(handler).toHaveBeenCalledWith({ type: 'expression:change', value: 'neutral' });
});

it('emits emotion:change event', () => {
const handler = vi.fn();
engine.on('emotion:change', handler);
Expand All @@ -173,6 +182,15 @@ describe('DigitalHumanEngine', () => {
expect(handler).toHaveBeenCalledWith({ type: 'emotion:change', value: 'happy' });
});

it('emits normalized emotion:change event for invalid emotions', () => {
const handler = vi.fn();
engine.on('emotion:change', handler);

engine.setEmotion('confused');

expect(handler).toHaveBeenCalledWith({ type: 'emotion:change', value: 'neutral' });
});

it('emits behavior:change event', () => {
const handler = vi.fn();
engine.on('behavior:change', handler);
Expand All @@ -181,6 +199,15 @@ describe('DigitalHumanEngine', () => {
expect(handler).toHaveBeenCalledWith({ type: 'behavior:change', value: 'thinking' });
});

it('emits normalized behavior:change event for invalid behaviors', () => {
const handler = vi.fn();
engine.on('behavior:change', handler);

engine.setBehavior('flying');

expect(handler).toHaveBeenCalledWith({ type: 'behavior:change', value: 'idle' });
});

it('emits animation:start and animation:end events', () => {
vi.useFakeTimers();
const startHandler = vi.fn();
Expand Down
8 changes: 8 additions & 0 deletions src/__tests__/useAdvancedDigitalHumanController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
reconnectMock: vi.fn(),
asrStartMock: vi.fn(),
asrStopMock: vi.fn(),
dialogueAbortPendingTurnMock: vi.fn(),
asrPerformGreetingMock: vi.fn(),
asrPerformDanceMock: vi.fn(),
clearRemoteSessionMock: vi.fn(),
Expand Down Expand Up @@ -73,6 +74,9 @@ vi.mock('../core/services', () => ({
performGreeting: mocks.asrPerformGreetingMock,
performDance: mocks.asrPerformDanceMock,
}),
useDialogue: () => ({
abortPendingTurn: mocks.dialogueAbortPendingTurnMock,
}),
useTTS: () => ({
speak: vi.fn(),
}),
Expand All @@ -92,6 +96,9 @@ vi.mock('../core/services', () => ({
performGreeting: mocks.asrPerformGreetingMock,
performDance: mocks.asrPerformDanceMock,
},
dialogue: {
abortPendingTurn: mocks.dialogueAbortPendingTurnMock,
},
tts: {},
}),
}));
Expand All @@ -115,6 +122,7 @@ describe('useAdvancedDigitalHumanController', () => {
mocks.reconnectMock.mockReset();
mocks.asrStartMock.mockReset();
mocks.asrStopMock.mockReset();
mocks.dialogueAbortPendingTurnMock.mockReset();
mocks.asrPerformGreetingMock.mockReset();
mocks.asrPerformDanceMock.mockReset();
mocks.clearRemoteSessionMock.mockReset();
Expand Down
18 changes: 16 additions & 2 deletions src/__tests__/useChatStream.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,35 @@ import { useSystemStore } from '../store/systemStore';

const runDialogueTurnStreamMock = vi.fn();
const abortPendingTurnMock = vi.fn();
const moduleRunDialogueTurnStreamMock = vi.fn((_: string, _options: unknown) => {
throw new Error('useChatStream should use useDialogue().runDialogueTurnStream');
});
const moduleAbortPendingTurnMock = vi.fn(() => {
throw new Error('useChatStream should use useDialogue().abortPendingTurn');
});

vi.mock('@/core/services', () => ({
useTTS: () => ({ speak: vi.fn() }),
useEngine: () => ({ setBehavior: vi.fn() }),
useDialogue: () => ({
abortPendingTurn: () => abortPendingTurnMock(),
runDialogueTurnStream: (text: string, options: unknown) =>
runDialogueTurnStreamMock(text, options),
}),
}));

vi.mock('../core/dialogue/dialogueOrchestrator', () => ({
runDialogueTurnStream: (...args: unknown[]) => runDialogueTurnStreamMock(...args),
abortPendingTurn: () => abortPendingTurnMock(),
runDialogueTurnStream: (text: string, options: unknown) =>
moduleRunDialogueTurnStreamMock(text, options),
abortPendingTurn: () => moduleAbortPendingTurnMock(),
}));

describe('useChatStream', () => {
beforeEach(() => {
runDialogueTurnStreamMock.mockReset();
abortPendingTurnMock.mockReset();
moduleRunDialogueTurnStreamMock.mockClear();
moduleAbortPendingTurnMock.mockClear();
useDigitalHumanStore.setState({
currentBehavior: 'idle',
});
Expand Down
Loading
Loading