🐛 fix(ai-panel): 隔离面板与消息级渲染异常避免整块白屏

- 为 AI 面板保留本地错误边界与重新加载兜底
- 为单条消息增加渲染隔离,异常消息不再拖垮整段对话
- 补充面板与消息渲染错误上下文,便于后续定位
This commit is contained in:
Syngnat
2026-05-30 17:26:52 +08:00
parent fdcbadf918
commit 5a52b141ed
4 changed files with 185 additions and 24 deletions

View File

@@ -9,12 +9,14 @@ const appSource = readFileSync(
describe('AI panel lazy-load guard', () => { describe('AI panel lazy-load guard', () => {
it('keeps AI panel failures scoped to the panel area with retry support', () => { it('keeps AI panel failures scoped to the panel area with retry support', () => {
expect(appSource).toContain('const createLazyAIChatPanel = () => React.lazy(() => import(\'./components/AIChatPanel\'));'); expect(appSource).toContain("import AIChatPanel from './components/AIChatPanel';");
expect(appSource).toContain('class AIPanelErrorBoundary extends React.Component'); expect(appSource).toContain('class AIPanelErrorBoundary extends React.Component');
expect(appSource).toContain('<AIPanelErrorBoundary'); expect(appSource).toContain('<AIPanelErrorBoundary');
expect(appSource).toContain('key={aiPanelRenderNonce}');
expect(appSource).toContain('AI 面板加载失败'); expect(appSource).toContain('AI 面板加载失败');
expect(appSource).toContain('重新加载'); expect(appSource).toContain('重新加载');
expect(appSource).toContain('setAiPanelRenderNonce((current) => current + 1)'); expect(appSource).toContain('setAiPanelRenderNonce((current) => current + 1)');
expect(appSource).toContain('<LazyAIChatPanel width={aiPanelRenderWidth}'); expect(appSource).toContain('<AIChatPanel width={aiPanelRenderWidth}');
expect(appSource).not.toContain('const loadAIChatPanelModule = async (retryNonce: number) => {');
}); });
}); });

View File

@@ -12,6 +12,7 @@ import DataSyncModal from './components/DataSyncModal';
import DriverManagerModal from './components/DriverManagerModal'; import DriverManagerModal from './components/DriverManagerModal';
import LogPanel from './components/LogPanel'; import LogPanel from './components/LogPanel';
import AISettingsModal from './components/AISettingsModal'; import AISettingsModal from './components/AISettingsModal';
import AIChatPanel from './components/AIChatPanel';
import SecurityUpdateBanner from './components/SecurityUpdateBanner'; import SecurityUpdateBanner from './components/SecurityUpdateBanner';
import SecurityUpdateIntroModal from './components/SecurityUpdateIntroModal'; import SecurityUpdateIntroModal from './components/SecurityUpdateIntroModal';
import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModal'; import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModal';
@@ -189,8 +190,6 @@ const createClosedConnectionPackageDialogState = (): ConnectionPackageDialogStat
confirmLoading: false, confirmLoading: false,
}); });
const createLazyAIChatPanel = () => React.lazy(() => import('./components/AIChatPanel'));
interface AIPanelErrorBoundaryProps { interface AIPanelErrorBoundaryProps {
children: React.ReactNode; children: React.ReactNode;
fallback: (error: Error | null) => React.ReactNode; fallback: (error: Error | null) => React.ReactNode;
@@ -328,7 +327,6 @@ function App() {
const windowDiagLastSignatureRef = React.useRef(''); const windowDiagLastSignatureRef = React.useRef('');
const windowDiagLastAtRef = React.useRef(0); const windowDiagLastAtRef = React.useRef(0);
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig); const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig);
const LazyAIChatPanel = useMemo(() => createLazyAIChatPanel(), [aiPanelRenderNonce]);
const securityUpdateStatusMeta = useMemo( const securityUpdateStatusMeta = useMemo(
() => getSecurityUpdateStatusMeta(securityUpdateStatus), () => getSecurityUpdateStatusMeta(securityUpdateStatus),
[securityUpdateStatus], [securityUpdateStatus],
@@ -2352,12 +2350,26 @@ function App() {
setIsModalOpen(true); setIsModalOpen(true);
}, []); }, []);
const handleEditConnection = (conn: SavedConnection) => { const handleEditConnection = useCallback((conn: SavedConnection) => {
setSecurityUpdateRepairSource(null); setSecurityUpdateRepairSource(null);
setEditingConnection(conn);
setIsConnectionModalMounted(true); setIsConnectionModalMounted(true);
setIsModalOpen(true); void (async () => {
}; const backendApp = (window as any).go?.app?.App;
let nextConnection = conn;
if (typeof backendApp?.GetEditableSavedConnection === 'function') {
try {
const editableConnection = await backendApp.GetEditableSavedConnection(conn.id);
if (editableConnection) {
nextConnection = editableConnection;
}
} catch (error: any) {
message.warning(error?.message || '读取已保存连接详情失败,当前将打开脱敏配置');
}
}
setEditingConnection(nextConnection);
setIsModalOpen(true);
})();
}, []);
useEffect(() => { useEffect(() => {
if (connectionModalWarmupDoneRef.current) { if (connectionModalWarmupDoneRef.current) {
@@ -2489,6 +2501,15 @@ function App() {
}, []); }, []);
const handleAIPanelRenderError = useCallback((error: Error, errorInfo: React.ErrorInfo) => { const handleAIPanelRenderError = useCallback((error: Error, errorInfo: React.ErrorInfo) => {
try {
(window as any).__gonaviLastAIPanelRenderError = {
message: error?.message || '',
stack: error?.stack || '',
componentStack: errorInfo?.componentStack || '',
};
} catch {
// ignore debug capture failures
}
console.error('AIChatPanel render error:', error, errorInfo); console.error('AIChatPanel render error:', error, errorInfo);
}, []); }, []);
@@ -3394,6 +3415,7 @@ function App() {
</> </>
)} )}
<AIPanelErrorBoundary <AIPanelErrorBoundary
key={aiPanelRenderNonce}
onError={handleAIPanelRenderError} onError={handleAIPanelRenderError}
fallback={(error) => ( fallback={(error) => (
<div <div
@@ -3449,11 +3471,9 @@ function App() {
</div> </div>
)} )}
> >
<React.Suspense fallback={<div style={{ width: aiPanelRenderWidth, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Spin size="small" /></div>}> <AIChatPanel width={aiPanelRenderWidth} darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => {
<LazyAIChatPanel width={aiPanelRenderWidth} darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => { handleOpenAISettings();
handleOpenAISettings(); }} overlayTheme={overlayTheme} />
}} overlayTheme={overlayTheme} />
</React.Suspense>
</AIPanelErrorBoundary> </AIPanelErrorBoundary>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8');
describe('AIChatPanel message render isolation', () => {
it('keeps per-message render failures scoped to the broken bubble', () => {
expect(source).toContain('class AIMessageRenderBoundary extends React.Component');
expect(source).toContain('[AI Message Render Error]');
expect(source).toContain('这条 AI 消息渲染失败,已自动隔离');
expect(source).toContain('__gonaviLastAIMessageRenderError');
expect(source).toContain('<AIMessageRenderBoundary');
expect(source).toContain('onDeleteMessage={handleDeleteMessage}');
});
});

View File

@@ -44,6 +44,109 @@ interface AIChatPanelProps {
const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
interface AIMessageRenderBoundaryProps {
children: React.ReactNode;
msg: AIChatMessage;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
onDeleteMessage: (id: string) => void;
onError?: (error: Error, errorInfo: React.ErrorInfo, msg: AIChatMessage) => void;
}
interface AIMessageRenderBoundaryState {
hasError: boolean;
error: Error | null;
}
class AIMessageRenderBoundary extends React.Component<
AIMessageRenderBoundaryProps,
AIMessageRenderBoundaryState
> {
constructor(props: AIMessageRenderBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): AIMessageRenderBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo, this.props.msg);
}
private handleRetryRender = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
const { msg, darkMode, overlayTheme, onDeleteMessage } = this.props;
return (
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
<div style={{
background: darkMode ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
border: `1px solid ${darkMode ? 'rgba(248,113,113,0.32)' : 'rgba(239,68,68,0.18)'}`,
borderRadius: 12,
padding: '14px 16px',
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: overlayTheme.titleText }}>
AI
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: overlayTheme.mutedText }}>
使
</div>
<div style={{
marginTop: 10,
padding: '8px 10px',
borderRadius: 8,
background: darkMode ? 'rgba(0,0,0,0.18)' : 'rgba(0,0,0,0.03)',
fontSize: 12,
color: overlayTheme.titleText,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}>
{this.state.error?.message || '未知渲染错误'}
</div>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button
type="button"
onClick={this.handleRetryRender}
style={{
border: overlayTheme.sectionBorder,
background: 'transparent',
color: overlayTheme.titleText,
borderRadius: 8,
padding: '6px 12px',
cursor: 'pointer',
}}
>
</button>
<button
type="button"
onClick={() => onDeleteMessage(msg.id)}
style={{
border: '1px solid rgba(239,68,68,0.28)',
background: darkMode ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
color: '#ef4444',
borderRadius: 8,
padding: '6px 12px',
cursor: 'pointer',
}}
>
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export const getDynamicMaxContextChars = (modelName?: string) => { export const getDynamicMaxContextChars = (modelName?: string) => {
if (!modelName) return 258000; // 默认 258k (2026主流基线) if (!modelName) return 258000; // 默认 258k (2026主流基线)
const lower = modelName.toLowerCase(); const lower = modelName.toLowerCase();
@@ -1604,6 +1707,19 @@ SELECT * FROM users WHERE status = 1;
// useMemo 缓存:避免内联闭包击穿子组件 memo // useMemo 缓存:避免内联闭包击穿子组件 memo
const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]); const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]);
const handleMessageRenderError = useCallback((error: Error, errorInfo: React.ErrorInfo, msg: AIChatMessage) => {
console.error('[AI Message Render Error]', msg.id, error, errorInfo);
if (typeof window !== 'undefined') {
(window as any).__gonaviLastAIMessageRenderError = {
messageId: msg.id,
role: msg.role,
contentPreview: String(msg.content || '').slice(0, 240),
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
};
}
}, []);
const activeConnectionConfig = useMemo(() => { const activeConnectionConfig = useMemo(() => {
if (!inferredConnectionId) return undefined; if (!inferredConnectionId) return undefined;
const connection = connections.find(c => c.id === inferredConnectionId); const connection = connections.find(c => c.id === inferredConnectionId);
@@ -1753,20 +1869,28 @@ SELECT * FROM users WHERE status = 1;
/> />
) : ( ) : (
messages.map(msg => ( messages.map(msg => (
<AIMessageBubble <AIMessageRenderBoundary
key={msg.id} key={msg.id}
msg={msg} msg={msg}
darkMode={darkMode} darkMode={darkMode}
overlayTheme={overlayTheme} overlayTheme={overlayTheme}
textColor={textColor} onDeleteMessage={handleDeleteMessage}
onEdit={handleEditMessage} onError={handleMessageRenderError}
onRetry={handleRetryMessage} >
onDelete={handleDeleteMessage} <AIMessageBubble
activeConnectionId={inferredConnectionId} msg={msg}
activeConnectionConfig={activeConnectionConfig} darkMode={darkMode}
activeDbName={inferredDbName} overlayTheme={overlayTheme}
allMessages={messages} textColor={textColor}
/> onEdit={handleEditMessage}
onRetry={handleRetryMessage}
onDelete={handleDeleteMessage}
activeConnectionId={inferredConnectionId}
activeConnectionConfig={activeConnectionConfig}
activeDbName={inferredDbName}
allMessages={messages}
/>
</AIMessageRenderBoundary>
)) ))
) )
)} )}