🐛 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

@@ -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)}`;
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) => {
if (!modelName) return 258000; // 默认 258k (2026主流基线)
const lower = modelName.toLowerCase();
@@ -1604,6 +1707,19 @@ SELECT * FROM users WHERE status = 1;
// useMemo 缓存:避免内联闭包击穿子组件 memo
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(() => {
if (!inferredConnectionId) return undefined;
const connection = connections.find(c => c.id === inferredConnectionId);
@@ -1753,20 +1869,28 @@ SELECT * FROM users WHERE status = 1;
/>
) : (
messages.map(msg => (
<AIMessageBubble
<AIMessageRenderBoundary
key={msg.id}
msg={msg}
darkMode={darkMode}
overlayTheme={overlayTheme}
textColor={textColor}
onEdit={handleEditMessage}
onRetry={handleRetryMessage}
onDelete={handleDeleteMessage}
activeConnectionId={inferredConnectionId}
activeConnectionConfig={activeConnectionConfig}
activeDbName={inferredDbName}
allMessages={messages}
/>
onDeleteMessage={handleDeleteMessage}
onError={handleMessageRenderError}
>
<AIMessageBubble
msg={msg}
darkMode={darkMode}
overlayTheme={overlayTheme}
textColor={textColor}
onEdit={handleEditMessage}
onRetry={handleRetryMessage}
onDelete={handleDeleteMessage}
activeConnectionId={inferredConnectionId}
activeConnectionConfig={activeConnectionConfig}
activeDbName={inferredDbName}
allMessages={messages}
/>
</AIMessageRenderBoundary>
))
)
)}