From 5a52b141edea160c4a79339ad080cd2def82b2af Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 30 May 2026 17:26:52 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(ai-panel):=20=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E9=9D=A2=E6=9D=BF=E4=B8=8E=E6=B6=88=E6=81=AF=E7=BA=A7?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=BC=82=E5=B8=B8=E9=81=BF=E5=85=8D=E6=95=B4?= =?UTF-8?q?=E5=9D=97=E7=99=BD=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 AI 面板保留本地错误边界与重新加载兜底 - 为单条消息增加渲染隔离,异常消息不再拖垮整段对话 - 补充面板与消息渲染错误上下文,便于后续定位 --- .../src/App.ai-panel-error-boundary.test.ts | 6 +- frontend/src/App.tsx | 44 ++++-- .../AIChatPanel.message-boundary.test.tsx | 15 ++ frontend/src/components/AIChatPanel.tsx | 144 ++++++++++++++++-- 4 files changed, 185 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/AIChatPanel.message-boundary.test.tsx diff --git a/frontend/src/App.ai-panel-error-boundary.test.ts b/frontend/src/App.ai-panel-error-boundary.test.ts index cb851b1..42f4b28 100644 --- a/frontend/src/App.ai-panel-error-boundary.test.ts +++ b/frontend/src/App.ai-panel-error-boundary.test.ts @@ -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(' current + 1)'); - expect(appSource).toContain(' {'); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 20afaa7..3cd99d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { )} (
)} > -
}> - setAIPanelVisible(false)} onOpenSettings={() => { - handleOpenAISettings(); - }} overlayTheme={overlayTheme} /> - + setAIPanelVisible(false)} onOpenSettings={() => { + handleOpenAISettings(); + }} overlayTheme={overlayTheme} />
diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx new file mode 100644 index 0000000..52c792b --- /dev/null +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -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(' `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 ( +
+
+
+ 这条 AI 消息渲染失败,已自动隔离 +
+
+ 其余对话仍可继续使用。你可以先删除这条异常消息,再继续操作。 +
+
+ {this.state.error?.message || '未知渲染错误'} +
+
+ + +
+
+
+ ); + } + + 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 => ( - + onDeleteMessage={handleDeleteMessage} + onError={handleMessageRenderError} + > + + )) ) )}