diff --git a/changelog/2026-05-22-dialogue-runtime-deepening.zh-CN.md b/changelog/2026-05-22-dialogue-runtime-deepening.zh-CN.md
new file mode 100644
index 0000000..e06fc2e
--- /dev/null
+++ b/changelog/2026-05-22-dialogue-runtime-deepening.zh-CN.md
@@ -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:通过
diff --git a/src/__tests__/ControlPanel.test.tsx b/src/__tests__/ControlPanel.test.tsx
new file mode 100644
index 0000000..630069e
--- /dev/null
+++ b/src/__tests__/ControlPanel.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ expect(onRender).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ useDigitalHumanStore.getState().setEmotion('happy');
+ });
+
+ expect(onRender).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/__tests__/ServicesProvider.test.tsx b/src/__tests__/ServicesProvider.test.tsx
index a2dd638..f87dc24 100644
--- a/src/__tests__/ServicesProvider.test.tsx
+++ b/src/__tests__/ServicesProvider.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ expect(captured.current).toBe(dialogue);
+ });
});
diff --git a/src/__tests__/audioService.test.ts b/src/__tests__/audioService.test.ts
new file mode 100644
index 0000000..7f345f6
--- /dev/null
+++ b/src/__tests__/audioService.test.ts
@@ -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 }
+ ).sendToDialogueService('你好');
+
+ expect(dialogue.runDialogueTurn).toHaveBeenCalledWith(
+ '你好',
+ expect.objectContaining({
+ sessionId: 'session_test',
+ isMuted: false,
+ }),
+ );
+ expect(moduleRunDialogueTurnMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/digitalHuman.test.tsx b/src/__tests__/digitalHuman.test.tsx
index 5f7f4cf..ba3c437 100644
--- a/src/__tests__/digitalHuman.test.tsx
+++ b/src/__tests__/digitalHuman.test.tsx
@@ -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
@@ -472,6 +475,7 @@ describe('ASRService', () => {
let mockState: any;
beforeEach(() => {
+ mockDialogue.runDialogueTurn.mockReset();
mockState = {
setRecording: vi.fn(),
setBehavior: vi.fn(),
@@ -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();
diff --git a/src/__tests__/digitalHumanEngine.test.ts b/src/__tests__/digitalHumanEngine.test.ts
index 9b92c91..132030a 100644
--- a/src/__tests__/digitalHumanEngine.test.ts
+++ b/src/__tests__/digitalHumanEngine.test.ts
@@ -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);
@@ -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);
@@ -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();
diff --git a/src/__tests__/useAdvancedDigitalHumanController.test.tsx b/src/__tests__/useAdvancedDigitalHumanController.test.tsx
index 6d64418..7005d39 100644
--- a/src/__tests__/useAdvancedDigitalHumanController.test.tsx
+++ b/src/__tests__/useAdvancedDigitalHumanController.test.tsx
@@ -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(),
@@ -73,6 +74,9 @@ vi.mock('../core/services', () => ({
performGreeting: mocks.asrPerformGreetingMock,
performDance: mocks.asrPerformDanceMock,
}),
+ useDialogue: () => ({
+ abortPendingTurn: mocks.dialogueAbortPendingTurnMock,
+ }),
useTTS: () => ({
speak: vi.fn(),
}),
@@ -92,6 +96,9 @@ vi.mock('../core/services', () => ({
performGreeting: mocks.asrPerformGreetingMock,
performDance: mocks.asrPerformDanceMock,
},
+ dialogue: {
+ abortPendingTurn: mocks.dialogueAbortPendingTurnMock,
+ },
tts: {},
}),
}));
@@ -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();
diff --git a/src/__tests__/useChatStream.test.tsx b/src/__tests__/useChatStream.test.tsx
index dd651c1..769dca2 100644
--- a/src/__tests__/useChatStream.test.tsx
+++ b/src/__tests__/useChatStream.test.tsx
@@ -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',
});
diff --git a/src/__tests__/useSessionManager.test.tsx b/src/__tests__/useSessionManager.test.tsx
new file mode 100644
index 0000000..e73a087
--- /dev/null
+++ b/src/__tests__/useSessionManager.test.tsx
@@ -0,0 +1,60 @@
+import { renderHook, act } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useSessionManager } from '@/hooks/useSessionManager';
+import { useChatSessionStore } from '@/store/chatSessionStore';
+import { useSystemStore } from '@/store/systemStore';
+
+const abortPendingTurnMock = vi.fn();
+const clearRemoteSessionMock = vi.fn();
+const toastSuccessMock = vi.fn();
+const moduleAbortPendingTurnMock = vi.fn(() => {
+ throw new Error('useSessionManager should use useDialogue().abortPendingTurn');
+});
+
+vi.mock('@/core/services', () => ({
+ useDialogue: () => ({
+ abortPendingTurn: () => abortPendingTurnMock(),
+ }),
+}));
+
+vi.mock('@/core/dialogue/dialogueOrchestrator', () => ({
+ abortPendingTurn: () => moduleAbortPendingTurnMock(),
+}));
+
+vi.mock('@/core/dialogue/dialogueService', () => ({
+ clearRemoteSession: (...args: unknown[]) => clearRemoteSessionMock(...args),
+}));
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: (...args: unknown[]) => toastSuccessMock(...args),
+ },
+}));
+
+describe('useSessionManager', () => {
+ beforeEach(() => {
+ abortPendingTurnMock.mockReset();
+ clearRemoteSessionMock.mockReset();
+ toastSuccessMock.mockReset();
+ moduleAbortPendingTurnMock.mockClear();
+
+ useChatSessionStore.setState({
+ sessionId: 'session_old',
+ chatHistory: [],
+ });
+ useSystemStore.getState().resetSystemState();
+ });
+
+ it('aborts provider dialogue before starting a new session', async () => {
+ const { result } = renderHook(() => useSessionManager());
+
+ await act(async () => {
+ result.current.handleNewSession();
+ await Promise.resolve();
+ });
+
+ expect(abortPendingTurnMock).toHaveBeenCalledTimes(1);
+ expect(clearRemoteSessionMock).toHaveBeenCalledWith('session_old');
+ expect(toastSuccessMock).toHaveBeenCalledWith('已开启新会话');
+ });
+});
diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx
index a99893a..b9b9bb9 100644
--- a/src/components/ControlPanel.tsx
+++ b/src/components/ControlPanel.tsx
@@ -13,7 +13,11 @@ import {
Loader2,
type LucideIcon,
} from 'lucide-react';
-import { useDigitalHumanStore } from '../store/digitalHumanStore';
+import {
+ selectCurrentBehavior,
+ selectIsSpeaking,
+ useDigitalHumanStore,
+} from '../store/digitalHumanStore';
import { useSystemStore, type ConnectionStatus } from '../store/systemStore';
interface ControlPanelProps {
@@ -71,7 +75,8 @@ export default function ControlPanel({
}: ControlPanelProps) {
// 从 store 获取状态
const connectionStatus = useSystemStore((s) => s.connectionStatus);
- const { isSpeaking, currentBehavior } = useDigitalHumanStore();
+ const isSpeaking = useDigitalHumanStore(selectIsSpeaking);
+ const currentBehavior = useDigitalHumanStore(selectCurrentBehavior);
// Memoize status configuration lookup
const statusConfig = useMemo(() => connectionStatusConfig[connectionStatus], [connectionStatus]);
diff --git a/src/core/ServicesProvider.tsx b/src/core/ServicesProvider.tsx
index fc3ff6a..45e1329 100644
--- a/src/core/ServicesProvider.tsx
+++ b/src/core/ServicesProvider.tsx
@@ -16,10 +16,6 @@ interface ServicesProviderProps {
children: ReactNode;
}
-type OptionalDialogueService = {
- reset?: () => void;
-};
-
/**
* 提供应用级服务单例。
* 在应用根组件包装使用。
@@ -32,16 +28,7 @@ export function ServicesProvider({ children }: ServicesProviderProps) {
services.asr.dispose();
services.tts.dispose();
services.engine.dispose();
-
- const dialogue = (
- services as typeof services & {
- dialogue?: OptionalDialogueService;
- }
- ).dialogue;
-
- if (typeof dialogue?.reset === 'function') {
- dialogue.reset();
- }
+ services.dialogue.reset();
};
}, [services]);
diff --git a/src/core/audio/audioService.ts b/src/core/audio/audioService.ts
index 4c3364e..15cdbe5 100644
--- a/src/core/audio/audioService.ts
+++ b/src/core/audio/audioService.ts
@@ -1,7 +1,7 @@
-import { runDialogueTurn } from '../dialogue/dialogueOrchestrator';
import { loggers } from '../../lib/logger';
import { VoiceCommandExecutor } from '../voiceCommand';
import type { TTSCallbacks, ASRStateAdapter } from '../adapters';
+import type { DialogueOrchestrator } from '../dialogue/dialogueOrchestrator';
// Re-export for backward compatibility
export type { TTSCallbacks, ASRStateAdapter } from '../adapters';
@@ -264,6 +264,8 @@ export interface ASRCallbacks {
onEnd?: () => void;
}
+type DialogueRuntime = Pick;
+
// ASR 配置接口
export interface ASRConfig {
lang?: string;
@@ -291,8 +293,14 @@ export class ASRService {
private pendingRestartTimer: ReturnType | null = null;
private recognitionGeneration = 0;
private voiceCommandExecutor: VoiceCommandExecutor;
+ private dialogue: DialogueRuntime;
- constructor(config: ASRConfig = {}, state: ASRStateAdapter, tts: TTSService) {
+ constructor(
+ config: ASRConfig = {},
+ state: ASRStateAdapter,
+ tts: TTSService,
+ dialogue: DialogueRuntime,
+ ) {
this.isSupportedFlag =
typeof window !== 'undefined' &&
('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
@@ -304,6 +312,7 @@ export class ASRService {
};
this.state = state;
this.tts = tts;
+ this.dialogue = dialogue;
// Initialize voice command executor
this.voiceCommandExecutor = new VoiceCommandExecutor({
@@ -565,7 +574,7 @@ export class ASRService {
// 发送到对话服务
private async sendToDialogueService(text: string): Promise {
try {
- await runDialogueTurn(text, {
+ await this.dialogue.runDialogueTurn(text, {
sessionId: this.state.sessionId,
isMuted: this.state.isMuted,
speakWith: (textToSpeak) => this.tts.speak(textToSpeak),
diff --git a/src/core/avatar/DigitalHumanEngine.ts b/src/core/avatar/DigitalHumanEngine.ts
index 75c0afb..b4094fe 100644
--- a/src/core/avatar/DigitalHumanEngine.ts
+++ b/src/core/avatar/DigitalHumanEngine.ts
@@ -69,13 +69,16 @@ export class DigitalHumanEngine {
}
setExpression(expression: string): void {
- if (VALID_EXPRESSIONS.includes(expression as ExpressionType)) {
- this.state.setExpression(expression as ExpressionType);
- } else {
+ const normalizedExpression = VALID_EXPRESSIONS.includes(expression as ExpressionType)
+ ? (expression as ExpressionType)
+ : 'neutral';
+
+ if (normalizedExpression === 'neutral' && expression !== 'neutral') {
logger.warn(`Unknown expression: ${expression}, falling back to neutral`);
- this.state.setExpression('neutral');
}
- this.emit('expression:change', expression);
+
+ this.state.setExpression(normalizedExpression);
+ this.emit('expression:change', normalizedExpression);
}
setExpressionIntensity(intensity: number): void {
@@ -83,28 +86,31 @@ export class DigitalHumanEngine {
}
setEmotion(emotion: string): void {
- if (VALID_EMOTIONS.includes(emotion as EmotionType)) {
- this.state.setEmotion(emotion as EmotionType);
- const mappedExpression = EMOTION_TO_EXPRESSION[emotion as EmotionType];
- if (mappedExpression) {
- this.state.setExpression(mappedExpression);
- }
- } else {
+ const normalizedEmotion = VALID_EMOTIONS.includes(emotion as EmotionType)
+ ? (emotion as EmotionType)
+ : 'neutral';
+
+ if (normalizedEmotion === 'neutral' && emotion !== 'neutral') {
logger.warn(`Unknown emotion: ${emotion}, falling back to neutral`);
- this.state.setEmotion('neutral');
- this.state.setExpression('neutral');
}
- this.emit('emotion:change', emotion);
+
+ this.state.setEmotion(normalizedEmotion);
+ const mappedExpression = EMOTION_TO_EXPRESSION[normalizedEmotion];
+ this.state.setExpression(mappedExpression ?? 'neutral');
+ this.emit('emotion:change', normalizedEmotion);
}
setBehavior(behavior: string, _params?: unknown): void {
- if (VALID_BEHAVIORS.includes(behavior as BehaviorType)) {
- this.state.setBehavior(behavior as BehaviorType);
- } else {
+ const normalizedBehavior = VALID_BEHAVIORS.includes(behavior as BehaviorType)
+ ? (behavior as BehaviorType)
+ : 'idle';
+
+ if (normalizedBehavior === 'idle' && behavior !== 'idle') {
logger.warn(`Unknown behavior: ${behavior}, falling back to idle`);
- this.state.setBehavior('idle');
}
- this.emit('behavior:change', behavior);
+
+ this.state.setBehavior(normalizedBehavior);
+ this.emit('behavior:change', normalizedBehavior);
}
playAnimation(name: string, autoReset: boolean = true): void {
diff --git a/src/core/createServices.ts b/src/core/createServices.ts
index 1ccc7b4..50d74a8 100644
--- a/src/core/createServices.ts
+++ b/src/core/createServices.ts
@@ -7,6 +7,7 @@
import { DigitalHumanEngine } from './avatar/DigitalHumanEngine';
import { TTSService, ASRService } from './audio/audioService';
import { createTTSCallbacks, createASRStateAdapter, createEngineStateAdapter } from './adapters';
+import { DialogueOrchestrator } from './dialogue/dialogueOrchestrator';
import type { Services } from './servicesContext';
// ============================================================================
@@ -22,15 +23,16 @@ export function createServices(): Services {
const ttsCallbacks = createTTSCallbacks();
const asrStateAdapter = createASRStateAdapter();
const engineStateAdapter = createEngineStateAdapter();
+ const dialogue = new DialogueOrchestrator();
// TTS 服务
const tts = new TTSService({}, ttsCallbacks);
// ASR 服务
- const asr = new ASRService({}, asrStateAdapter, tts);
+ const asr = new ASRService({}, asrStateAdapter, tts, dialogue);
// DigitalHumanEngine
const engine = new DigitalHumanEngine(engineStateAdapter);
- return { engine, tts, asr };
+ return { engine, tts, asr, dialogue };
}
diff --git a/src/core/serviceHooks.ts b/src/core/serviceHooks.ts
index 42bf53c..7480bd0 100644
--- a/src/core/serviceHooks.ts
+++ b/src/core/serviceHooks.ts
@@ -7,7 +7,9 @@
import { useContext } from 'react';
import { DigitalHumanEngine } from './avatar/DigitalHumanEngine';
import { TTSService, ASRService } from './audio/audioService';
+import { DialogueOrchestrator } from './dialogue/dialogueOrchestrator';
import { ServicesContext } from './servicesContext';
+import type { Services } from './servicesContext';
// ============================================================================
// Hooks
@@ -17,7 +19,7 @@ import { ServicesContext } from './servicesContext';
* 获取服务容器。
* 必须在 ServicesProvider 内使用。
*/
-export function useServices(): { engine: DigitalHumanEngine; tts: TTSService; asr: ASRService } {
+export function useServices(): Services {
const services = useContext(ServicesContext);
if (!services) {
throw new Error('useServices must be used within ServicesProvider');
@@ -45,3 +47,10 @@ export function useTTS(): TTSService {
export function useASR(): ASRService {
return useServices().asr;
}
+
+/**
+ * 获取 DialogueOrchestrator。
+ */
+export function useDialogue(): DialogueOrchestrator {
+ return useServices().dialogue;
+}
diff --git a/src/core/services.ts b/src/core/services.ts
index ac169bc..cef9489 100644
--- a/src/core/services.ts
+++ b/src/core/services.ts
@@ -15,7 +15,7 @@ export { ServicesProvider } from './ServicesProvider';
export { createServices } from './createServices';
// Hooks
-export { useServices, useEngine, useTTS, useASR } from './serviceHooks';
+export { useServices, useEngine, useTTS, useASR, useDialogue } from './serviceHooks';
// 类型(供外部使用)
export type { StateAdapter } from './avatar/DigitalHumanEngine';
diff --git a/src/core/servicesContext.ts b/src/core/servicesContext.ts
index 70210cd..663fdc5 100644
--- a/src/core/servicesContext.ts
+++ b/src/core/servicesContext.ts
@@ -7,6 +7,7 @@
import { createContext } from 'react';
import { DigitalHumanEngine } from './avatar/DigitalHumanEngine';
import { TTSService, ASRService } from './audio/audioService';
+import { DialogueOrchestrator } from './dialogue/dialogueOrchestrator';
// ============================================================================
// 服务接口
@@ -16,6 +17,7 @@ export interface Services {
engine: DigitalHumanEngine;
tts: TTSService;
asr: ASRService;
+ dialogue: DialogueOrchestrator;
}
// ============================================================================
diff --git a/src/hooks/useChatStream.ts b/src/hooks/useChatStream.ts
index a392021..befefcf 100644
--- a/src/hooks/useChatStream.ts
+++ b/src/hooks/useChatStream.ts
@@ -1,11 +1,10 @@
import { useState, useCallback, useEffect, useRef } from 'react';
-import { useDigitalHumanStore } from '../store/digitalHumanStore';
-import { useChatSessionStore } from '../store/chatSessionStore';
-import { useSystemStore } from '../store/systemStore';
-import { useTTS, useEngine } from '@/core/services';
-import { abortPendingTurn, runDialogueTurnStream } from '../core/dialogue/dialogueOrchestrator';
+import { useDigitalHumanStore } from '@/store/digitalHumanStore';
+import { useChatSessionStore } from '@/store/chatSessionStore';
+import { useSystemStore } from '@/store/systemStore';
+import { useTTS, useEngine, useDialogue } from '@/core/services';
import { toast } from 'sonner';
-import { loggers } from '../lib/logger';
+import { loggers } from '@/lib/logger';
const logger = loggers.chat;
@@ -20,6 +19,7 @@ export interface UseChatStreamOptions {
export function useChatStream(options: UseChatStreamOptions) {
const tts = useTTS();
const engine = useEngine();
+ const dialogue = useDialogue();
const addChatMessage = useChatSessionStore((s) => s.addChatMessage);
const updateChatMessage = useChatSessionStore((s) => s.updateChatMessage);
const removeChatMessage = useChatSessionStore((s) => s.removeChatMessage);
@@ -35,9 +35,9 @@ export function useChatStream(options: UseChatStreamOptions) {
useEffect(() => {
return () => {
activeTurnRef.current = null;
- abortPendingTurn();
+ dialogue.abortPendingTurn();
};
- }, [sessionId]);
+ }, [dialogue, sessionId]);
const handleChatSend = useCallback(
async (text?: string) => {
@@ -124,7 +124,7 @@ export function useChatStream(options: UseChatStreamOptions) {
try {
startChatPerformanceTrace();
- const result = await runDialogueTurnStream(content, {
+ const result = await dialogue.runDialogueTurnStream(content, {
sessionId,
meta: { timestamp: Date.now() },
engine,
@@ -234,6 +234,7 @@ export function useChatStream(options: UseChatStreamOptions) {
onConnectionChange,
onClearError,
onError,
+ dialogue,
],
);
diff --git a/src/hooks/useSessionManager.ts b/src/hooks/useSessionManager.ts
index 2218393..76766ea 100644
--- a/src/hooks/useSessionManager.ts
+++ b/src/hooks/useSessionManager.ts
@@ -8,17 +8,18 @@ import { useCallback } from 'react';
import { toast } from 'sonner';
import { useChatSessionStore } from '@/store/chatSessionStore';
import { useSystemStore } from '@/store/systemStore';
+import { useDialogue } from '@/core/services';
import { clearRemoteSession } from '@/core/dialogue/dialogueService';
-import { abortPendingTurn } from '@/core/dialogue/dialogueOrchestrator';
export function useSessionManager() {
+ const dialogue = useDialogue();
const sessionId = useChatSessionStore((s) => s.sessionId);
const initChatSession = useChatSessionStore((s) => s.initSession);
const resetSystemState = useSystemStore((s) => s.resetSystemState);
const handleNewSession = useCallback(() => {
const oldSessionId = sessionId;
- abortPendingTurn();
+ dialogue.abortPendingTurn();
// 协调多 store 初始化
initChatSession();
@@ -28,7 +29,7 @@ export function useSessionManager() {
// 清理远程会话(fire and forget)
void clearRemoteSession(oldSessionId);
- }, [sessionId, initChatSession, resetSystemState]);
+ }, [dialogue, sessionId, initChatSession, resetSystemState]);
return {
sessionId,
diff --git a/src/pages/DigitalHumanPage.tsx b/src/pages/DigitalHumanPage.tsx
index 9ee109d..f785cdc 100644
--- a/src/pages/DigitalHumanPage.tsx
+++ b/src/pages/DigitalHumanPage.tsx
@@ -1,7 +1,12 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { DigitalHumanViewer } from '@/components/viewer';
import ControlPanel from '@/components/ControlPanel';
-import { useDigitalHumanStore } from '@/store/digitalHumanStore';
+import {
+ selectIsPlaying,
+ selectIsRecording,
+ selectIsSpeaking,
+ useDigitalHumanStore,
+} from '@/store/digitalHumanStore';
import { useSystemStore } from '@/store/systemStore';
import { useEngine, useTTS, useASR } from '@/core/services';
import { VoiceCommandExecutor } from '@/core/voiceCommand/executor';
@@ -13,16 +18,14 @@ export default function DigitalHumanPage() {
const tts = useTTS();
const asr = useASR();
- const {
- isPlaying,
- isRecording,
- isMuted,
- autoRotate,
- isSpeaking,
- setRecording,
- toggleMute,
- toggleAutoRotate,
- } = useDigitalHumanStore();
+ const isPlaying = useDigitalHumanStore(selectIsPlaying);
+ const isRecording = useDigitalHumanStore(selectIsRecording);
+ const isMuted = useDigitalHumanStore((s) => s.isMuted);
+ const autoRotate = useDigitalHumanStore((s) => s.autoRotate);
+ const isSpeaking = useDigitalHumanStore(selectIsSpeaking);
+ const setRecording = useDigitalHumanStore((s) => s.setRecording);
+ const toggleMute = useDigitalHumanStore((s) => s.toggleMute);
+ const toggleAutoRotate = useDigitalHumanStore((s) => s.toggleAutoRotate);
const connectionStatus = useSystemStore((s) => s.connectionStatus);
const [modelLoaded, setModelLoaded] = useState(false);