mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-13 01:49:41 +08:00
🐛 fix(ai-panel): 隔离面板与消息级渲染异常避免整块白屏
- 为 AI 面板保留本地错误边界与重新加载兜底 - 为单条消息增加渲染隔离,异常消息不再拖垮整段对话 - 补充面板与消息渲染错误上下文,便于后续定位
This commit is contained in:
@@ -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) => {');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}`;
|
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>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user