mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-12 17:39:42 +08:00
🐛 fix(ai-panel): 隔离面板与消息级渲染异常避免整块白屏
- 为 AI 面板保留本地错误边界与重新加载兜底 - 为单条消息增加渲染隔离,异常消息不再拖垮整段对话 - 补充面板与消息渲染错误上下文,便于后续定位
This commit is contained in:
@@ -9,12 +9,14 @@ const appSource = readFileSync(
|
||||
|
||||
describe('AI panel lazy-load guard', () => {
|
||||
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('<AIPanelErrorBoundary');
|
||||
expect(appSource).toContain('key={aiPanelRenderNonce}');
|
||||
expect(appSource).toContain('AI 面板加载失败');
|
||||
expect(appSource).toContain('重新加载');
|
||||
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) => {');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import DataSyncModal from './components/DataSyncModal';
|
||||
import DriverManagerModal from './components/DriverManagerModal';
|
||||
import LogPanel from './components/LogPanel';
|
||||
import AISettingsModal from './components/AISettingsModal';
|
||||
import AIChatPanel from './components/AIChatPanel';
|
||||
import SecurityUpdateBanner from './components/SecurityUpdateBanner';
|
||||
import SecurityUpdateIntroModal from './components/SecurityUpdateIntroModal';
|
||||
import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModal';
|
||||
@@ -189,8 +190,6 @@ const createClosedConnectionPackageDialogState = (): ConnectionPackageDialogStat
|
||||
confirmLoading: false,
|
||||
});
|
||||
|
||||
const createLazyAIChatPanel = () => React.lazy(() => import('./components/AIChatPanel'));
|
||||
|
||||
interface AIPanelErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
fallback: (error: Error | null) => React.ReactNode;
|
||||
@@ -328,7 +327,6 @@ function App() {
|
||||
const windowDiagLastSignatureRef = React.useRef('');
|
||||
const windowDiagLastAtRef = React.useRef(0);
|
||||
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig);
|
||||
const LazyAIChatPanel = useMemo(() => createLazyAIChatPanel(), [aiPanelRenderNonce]);
|
||||
const securityUpdateStatusMeta = useMemo(
|
||||
() => getSecurityUpdateStatusMeta(securityUpdateStatus),
|
||||
[securityUpdateStatus],
|
||||
@@ -2352,12 +2350,26 @@ function App() {
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditConnection = (conn: SavedConnection) => {
|
||||
const handleEditConnection = useCallback((conn: SavedConnection) => {
|
||||
setSecurityUpdateRepairSource(null);
|
||||
setEditingConnection(conn);
|
||||
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(() => {
|
||||
if (connectionModalWarmupDoneRef.current) {
|
||||
@@ -2489,6 +2501,15 @@ function App() {
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
@@ -3394,6 +3415,7 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
<AIPanelErrorBoundary
|
||||
key={aiPanelRenderNonce}
|
||||
onError={handleAIPanelRenderError}
|
||||
fallback={(error) => (
|
||||
<div
|
||||
@@ -3449,11 +3471,9 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<React.Suspense fallback={<div style={{ width: aiPanelRenderWidth, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Spin size="small" /></div>}>
|
||||
<LazyAIChatPanel width={aiPanelRenderWidth} darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => {
|
||||
handleOpenAISettings();
|
||||
}} overlayTheme={overlayTheme} />
|
||||
</React.Suspense>
|
||||
<AIChatPanel width={aiPanelRenderWidth} darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => {
|
||||
handleOpenAISettings();
|
||||
}} overlayTheme={overlayTheme} />
|
||||
</AIPanelErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user