From dbc3e52ac66245f0472257d3279c5dc4fbf9f4a6 Mon Sep 17 00:00:00 2001 From: shijiashuai Date: Fri, 22 May 2026 10:33:46 +0800 Subject: [PATCH] refactor: scope dialogue runtime to services Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-05-22-dialogue-runtime-deepening.zh-CN.md | 40 +++++++++++++ src/__tests__/ControlPanel.test.tsx | 50 ++++++++++++++++ src/__tests__/ServicesProvider.test.tsx | 39 ++++++++++++ src/__tests__/audioService.test.ts | 55 +++++++++++++++++ src/__tests__/digitalHuman.test.tsx | 10 +++- src/__tests__/digitalHumanEngine.test.ts | 27 +++++++++ ...useAdvancedDigitalHumanController.test.tsx | 8 +++ src/__tests__/useChatStream.test.tsx | 18 +++++- src/__tests__/useSessionManager.test.tsx | 60 +++++++++++++++++++ src/components/ControlPanel.tsx | 9 ++- src/core/ServicesProvider.tsx | 15 +---- src/core/audio/audioService.ts | 15 ++++- src/core/avatar/DigitalHumanEngine.ts | 46 +++++++------- src/core/createServices.ts | 6 +- src/core/serviceHooks.ts | 11 +++- src/core/services.ts | 2 +- src/core/servicesContext.ts | 2 + src/hooks/useChatStream.ts | 19 +++--- src/hooks/useSessionManager.ts | 7 ++- src/pages/DigitalHumanPage.tsx | 25 ++++---- 20 files changed, 393 insertions(+), 71 deletions(-) create mode 100644 changelog/2026-05-22-dialogue-runtime-deepening.zh-CN.md create mode 100644 src/__tests__/ControlPanel.test.tsx create mode 100644 src/__tests__/audioService.test.ts create mode 100644 src/__tests__/useSessionManager.test.tsx 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);