Skip to content

Commit 825cf9f

Browse files
author
shijiashuai
committed
fix: 修复内存泄漏、焦点陷阱、语音命令误触发、WebSocket 异常处理等 5 项 bug
- DigitalHumanViewer 新增 useEffect 清理 GLTF 场景资源(geometry/material dispose) - useFocusTrap 接受 focusKey 参数,SettingsDrawer 切换 tab 时重新聚焦首个元素 - ASRService 本地命令改为精确匹配(trim + 完整词),避免"你好"等常用词误触发 - wsClient connect() 保存 resolve/reject 引用,disconnect 时主动拒绝 pending Promise - chatTransport WebSocket 流将
1 parent fe12255 commit 825cf9f

File tree

8 files changed

+98
-57
lines changed

8 files changed

+98
-57
lines changed

src/components/DigitalHumanViewer.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,24 @@ export default function DigitalHumanViewer({
313313
};
314314
}, [modelUrl]);
315315

316+
// Dispose old GLTF scene resources when modelScene is replaced
317+
useEffect(() => {
318+
return () => {
319+
modelScene?.traverse((child) => {
320+
if ((child as THREE.Mesh).isMesh) {
321+
const mesh = child as THREE.Mesh;
322+
mesh.geometry.dispose();
323+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
324+
materials.forEach((mat) => {
325+
if (mat instanceof THREE.Material) {
326+
mat.dispose();
327+
}
328+
});
329+
}
330+
});
331+
};
332+
}, [modelScene]);
333+
316334
return (
317335
<div className="w-full h-full bg-transparent space-y-4" role="img" aria-label="3D数字人模型">
318336
<Canvas shadows dpr={[1, 2]}>

src/components/SettingsDrawer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export default function SettingsDrawer({
5151
const currentExpression = useDigitalHumanStore((s) => s.currentExpression);
5252
const currentBehavior = useDigitalHumanStore((s) => s.currentBehavior);
5353

54-
const drawerRef = useFocusTrap<HTMLDivElement>(show);
54+
const drawerRef = useFocusTrap<HTMLDivElement>(show, activeTab);
5555
const { toggleTheme, isDark } = useTheme();
5656

5757
return (

src/core/audio/audioService.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -509,44 +509,44 @@ export class ASRService {
509509

510510
// 尝试执行本地命令,返回是否匹配到命令
511511
private tryLocalCommand(command: string): boolean {
512-
const lowerCommand = command.toLowerCase();
512+
const trimmed = command.trim().toLowerCase();
513513

514-
// 系统控制命令
515-
if (lowerCommand.includes('播放') || lowerCommand.includes('开始')) {
514+
// System control commands — exact match only
515+
if (trimmed === '播放' || trimmed === '开始') {
516516
this.state.play();
517517
return true;
518518
}
519-
if (lowerCommand.includes('暂停') || lowerCommand.includes('停止')) {
519+
if (trimmed === '暂停' || trimmed === '停止') {
520520
this.state.pause();
521521
return true;
522522
}
523-
if (lowerCommand.includes('重置') || lowerCommand.includes('复位')) {
523+
if (trimmed === '重置' || trimmed === '复位') {
524524
this.state.reset();
525525
return true;
526526
}
527-
if (lowerCommand.includes('取消静音')) {
527+
if (trimmed === '取消静音') {
528528
this.state.setMuted(false);
529529
return true;
530530
}
531-
if (lowerCommand.includes('静音')) {
531+
if (trimmed === '静音') {
532532
this.state.setMuted(true);
533533
return true;
534534
}
535535

536-
// 快捷动作命令
537-
if (lowerCommand.includes('打招呼') || lowerCommand.includes('问好') || lowerCommand.includes('你好')) {
536+
// Quick action commands — exact match only
537+
if (trimmed === '打招呼' || trimmed === '问好') {
538538
this.performGreeting();
539539
return true;
540540
}
541-
if (lowerCommand.includes('跳舞')) {
541+
if (trimmed === '跳舞') {
542542
this.performDance();
543543
return true;
544544
}
545-
if (lowerCommand.includes('点头')) {
545+
if (trimmed === '点头') {
546546
this.performNod();
547547
return true;
548548
}
549-
if (lowerCommand.includes('摇头')) {
549+
if (trimmed === '摇头') {
550550
this.performShakeHead();
551551
return true;
552552
}

src/core/dialogue/chatTransport.ts

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,40 @@ async function* streamOverWebSocket(
162162
pendingResolver = resolve;
163163
});
164164
}
165+
166+
// Build return value from terminal event or accumulated text
167+
if (terminalEvent?.type === 'done') {
168+
return {
169+
response: {
170+
replyText: terminalEvent.replyText,
171+
emotion: terminalEvent.emotion as ChatResponsePayload['emotion'],
172+
action: terminalEvent.action,
173+
},
174+
connectionStatus: 'connected',
175+
error: null,
176+
};
177+
}
178+
179+
if (accumulatedText) {
180+
return {
181+
response: {
182+
replyText: accumulatedText,
183+
emotion: 'neutral',
184+
action: 'idle',
185+
},
186+
connectionStatus: 'error',
187+
error: terminalEvent?.type === 'error' ? terminalEvent.message : 'WebSocket 流中断',
188+
};
189+
}
190+
191+
// No tokens received and no terminal event — HTTP fallback
192+
const fallback = await sendUserInput(payload, config);
193+
194+
if (fallback.response.replyText) {
195+
yield fallback.response.replyText;
196+
}
197+
198+
return fallback;
165199
} catch (error) {
166200
console.warn('WebSocket 请求失败,降级到 HTTP:', error);
167201
const fallback = await sendUserInput(payload, config);
@@ -177,38 +211,6 @@ async function* streamOverWebSocket(
177211
}
178212
client.disconnect();
179213
}
180-
181-
if (terminalEvent?.type === 'done') {
182-
return {
183-
response: {
184-
replyText: terminalEvent.replyText,
185-
emotion: terminalEvent.emotion as ChatResponsePayload['emotion'],
186-
action: terminalEvent.action,
187-
},
188-
connectionStatus: 'connected',
189-
error: null,
190-
};
191-
}
192-
193-
if (accumulatedText) {
194-
return {
195-
response: {
196-
replyText: accumulatedText,
197-
emotion: 'neutral',
198-
action: 'idle',
199-
},
200-
connectionStatus: 'error',
201-
error: terminalEvent?.type === 'error' ? terminalEvent.message : 'WebSocket 流中断',
202-
};
203-
}
204-
205-
const fallback = await sendUserInput(payload, config);
206-
207-
if (fallback.response.replyText) {
208-
yield fallback.response.replyText;
209-
}
210-
211-
return fallback;
212214
}
213215

214216
export const webSocketChatTransport: ChatTransport = {

src/core/dialogue/dialogueOrchestrator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ export async function runDialogueTurnStream(
190190
await handleDialogueResponse(result.response, {
191191
isMuted,
192192
speakWith,
193-
onAddAssistantMessage: undefined,
193+
onAddAssistantMessage: options.onAddAssistantMessage,
194194
onError,
195195
});
196196

src/core/dialogue/wsClient.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export class MetaHumanWSClient {
6767
private messageHandler: WSMessageHandler | null = null;
6868
private reconnectAttempts = 0;
6969
private maxReconnectAttempts = 5;
70+
private resolveConnect: (() => void) | null = null;
71+
private rejectConnect: ((reason?: unknown) => void) | null = null;
7072

7173
constructor(sessionId: string) {
7274
this.sessionId = sessionId;
@@ -75,11 +77,15 @@ export class MetaHumanWSClient {
7577
connect(onMessage: WSMessageHandler): Promise<void> {
7678
return new Promise((resolve, reject) => {
7779
this.messageHandler = onMessage;
80+
this.resolveConnect = resolve;
81+
this.rejectConnect = reject;
7882
const url = getWebSocketUrl(this.sessionId);
7983
this.ws = new WebSocket(url);
8084

8185
this.ws.onopen = () => {
8286
this.reconnectAttempts = 0;
87+
this.resolveConnect = null;
88+
this.rejectConnect = null;
8389
resolve();
8490
};
8591

@@ -92,7 +98,11 @@ export class MetaHumanWSClient {
9298
}
9399
};
94100

95-
this.ws.onerror = (event) => reject(event);
101+
this.ws.onerror = (event) => {
102+
this.resolveConnect = null;
103+
this.rejectConnect = null;
104+
reject(event);
105+
};
96106

97107
this.ws.onclose = () => this.attemptReconnect();
98108
});
@@ -106,6 +116,11 @@ export class MetaHumanWSClient {
106116

107117
disconnect(): void {
108118
this.reconnectAttempts = this.maxReconnectAttempts;
119+
if (this.rejectConnect) {
120+
this.rejectConnect(new Error('WebSocket disconnected'));
121+
this.rejectConnect = null;
122+
this.resolveConnect = null;
123+
}
109124
this.ws?.close();
110125
this.ws = null;
111126
}

src/hooks/useChatStream.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,16 @@ export function useChatStream(options: UseChatStreamOptions) {
2222
const startChatPerformanceTrace = useSystemStore((s) => s.startChatPerformanceTrace);
2323
const markChatFirstToken = useSystemStore((s) => s.markChatFirstToken);
2424
const finalizeChatPerformanceTrace = useSystemStore((s) => s.finalizeChatPerformanceTrace);
25+
const setLoading = useSystemStore((s) => s.setLoading);
26+
const isLoading = useSystemStore((s) => s.isLoading);
2527
const [chatInput, setChatInput] = useState('');
26-
const [isChatLoading, setIsChatLoading] = useState(false);
2728
const { sessionId, isMuted, onConnectionChange, onClearError, onError } = options;
2829

2930
const handleChatSend = useCallback(
3031
async (text?: string) => {
3132
const content = (text ?? chatInput).trim();
32-
if (!content || isChatLoading) return;
33+
if (!content) return;
34+
if (useSystemStore.getState().isLoading) return;
3335

3436
if (!text) setChatInput('');
3537

@@ -73,7 +75,8 @@ export function useChatStream(options: UseChatStreamOptions) {
7375
meta: { timestamp: Date.now() },
7476
isMuted,
7577
speakWith: (textToSpeak) => ttsService.speak(textToSpeak),
76-
setLoading: setIsChatLoading,
78+
setLoading,
79+
onAddAssistantMessage: undefined,
7780
onAddUserMessage: (t) => {
7881
addChatMessage('user', t);
7982
assistantMessageId = addChatMessage('assistant', '', true);
@@ -116,13 +119,13 @@ export function useChatStream(options: UseChatStreamOptions) {
116119
},
117120
[
118121
chatInput,
119-
isChatLoading,
120122
addChatMessage,
121123
updateChatMessage,
122124
removeChatMessage,
123125
startChatPerformanceTrace,
124126
markChatFirstToken,
125127
finalizeChatPerformanceTrace,
128+
setLoading,
126129
sessionId,
127130
isMuted,
128131
onConnectionChange,
@@ -131,5 +134,5 @@ export function useChatStream(options: UseChatStreamOptions) {
131134
],
132135
);
133136

134-
return { chatInput, setChatInput, isChatLoading, handleChatSend };
137+
return { chatInput, setChatInput, isChatLoading: isLoading, handleChatSend };
135138
}

src/hooks/useFocusTrap.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import { useEffect, useRef } from 'react';
22

33
/**
44
* Traps keyboard focus within a container element when active.
5-
* Tab/Shift+Tab cycle through focusable elements. On activation,
6-
* focuses the first focusable element.
5+
* Tab/Shift+Tab cycle through focusable elements. On activation
6+
* or when focusKey changes, focuses the first focusable element.
77
*/
8-
export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(isActive: boolean) {
8+
export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(
9+
isActive: boolean,
10+
focusKey?: unknown,
11+
) {
912
const containerRef = useRef<T>(null);
1013

1114
useEffect(() => {
@@ -17,7 +20,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(isActive: b
1720
const getFocusable = () =>
1821
Array.from(container.querySelectorAll<HTMLElement>(selector));
1922

20-
// Focus first element on activation
23+
// Focus first element on activation or focusKey change
2124
const focusable = getFocusable();
2225
if (focusable.length > 0) {
2326
focusable[0].focus();
@@ -43,7 +46,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(isActive: b
4346

4447
container.addEventListener('keydown', handleKeyDown);
4548
return () => container.removeEventListener('keydown', handleKeyDown);
46-
}, [isActive]);
49+
}, [isActive, focusKey]);
4750

4851
return containerRef;
4952
}

0 commit comments

Comments
 (0)