From 86095b5bf15a2130216b476ce888c3bc148f3fdb Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 9 Jun 2026 08:29:02 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai-chat):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=BC=82=E5=B8=B8=E6=8E=A2=E9=92=88=E5=B9=B6?= =?UTF-8?q?=E6=8B=86=E5=88=86=E8=81=8A=E5=A4=A9=E9=9D=A2=E6=9D=BF=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AIChatPanel.message-boundary.test.tsx | 21 +- frontend/src/components/AIChatPanel.tsx | 245 ++++-------------- .../ai/AIBuiltinToolsCatalog.test.tsx | 2 + .../components/ai/AIBuiltinToolsCatalog.tsx | 5 + .../ai/aiLastRenderErrorInsights.ts | 56 ++++ ...iLocalToolExecutor.lastRenderError.test.ts | 69 +++++ .../src/components/ai/aiSlashCommands.test.ts | 6 + frontend/src/components/ai/aiSlashCommands.ts | 1 + .../ai/aiSnapshotInspectionToolExecutor.ts | 7 + .../ai/aiSystemContextMessages.test.ts | 3 +- .../components/ai/aiSystemContextMessages.ts | 14 + .../messageBubble/AIMessageStatusBlocks.tsx | 1 + .../src/components/ai/useAIChatAutoContext.ts | 77 ++++++ .../src/components/ai/useAIChatPanelResize.ts | 106 ++++++++ .../components/ai/useAIChatPlanContexts.ts | 60 +++++ .../components/ai/useAIChatSessionState.ts | 57 ++++ .../src/utils/aiBuiltinInspectionToolInfo.ts | 17 ++ frontend/src/utils/aiToolRegistry.test.ts | 11 + 18 files changed, 559 insertions(+), 199 deletions(-) create mode 100644 frontend/src/components/ai/aiLastRenderErrorInsights.ts create mode 100644 frontend/src/components/ai/aiLocalToolExecutor.lastRenderError.test.ts create mode 100644 frontend/src/components/ai/useAIChatAutoContext.ts create mode 100644 frontend/src/components/ai/useAIChatPanelResize.ts create mode 100644 frontend/src/components/ai/useAIChatPlanContexts.ts create mode 100644 frontend/src/components/ai/useAIChatSessionState.ts diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index d07ea4f..a86b554 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -5,8 +5,12 @@ const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8 const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8'); const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversationView.tsx', import.meta.url), 'utf8'); const derivedStateSource = readFileSync(new URL('./ai/aiChatPanelDerivedState.ts', import.meta.url), 'utf8'); +const autoContextSource = readFileSync(new URL('./ai/useAIChatAutoContext.ts', import.meta.url), 'utf8'); const payloadDispatchSource = readFileSync(new URL('./ai/aiChatPayloadDispatch.ts', import.meta.url), 'utf8'); +const planContextSource = readFileSync(new URL('./ai/useAIChatPlanContexts.ts', import.meta.url), 'utf8'); +const resizeSource = readFileSync(new URL('./ai/useAIChatPanelResize.ts', import.meta.url), 'utf8'); const runtimeResourcesSource = readFileSync(new URL('./ai/useAIChatRuntimeResources.ts', import.meta.url), 'utf8'); +const sessionStateSource = readFileSync(new URL('./ai/useAIChatSessionState.ts', import.meta.url), 'utf8'); const streamSubscriptionSource = readFileSync(new URL('./ai/useAIChatStreamSubscription.ts', import.meta.url), 'utf8'); const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8'); const runtimeSource = readFileSync(new URL('../utils/aiChatRuntime.ts', import.meta.url), 'utf8'); @@ -71,12 +75,25 @@ describe('AIChatPanel message render isolation', () => { }); it('keeps the v2 history mode sorted by the latest updated session first', () => { - expect(source).toContain('const orderedAISessions = useMemo('); - expect(source).toContain('right.updatedAt - left.updatedAt'); + expect(source).toContain("import { useAIChatSessionState } from './ai/useAIChatSessionState';"); expect(source).toContain('const panelHistorySessions = useMemo('); + expect(sessionStateSource).toContain('right.updatedAt - left.updatedAt'); + expect(sessionStateSource).toContain("const sid = aiActiveSessionId || 'session-fallback';"); expect(source).toContain('buildAIChatInlineHistorySessions(orderedAISessions)'); expect(derivedStateSource).toContain('export const buildAIChatInlineHistorySessions'); expect(derivedStateSource).toContain('sessions.slice(0, limit)'); expect(source).toContain('sessions={panelHistorySessions}'); }); + + it('extracts plan-context, auto-context, and resize hooks so the panel file stays focused on orchestration', () => { + expect(source).toContain("import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts';"); + expect(source).toContain("import { useAIChatAutoContext } from './ai/useAIChatAutoContext';"); + expect(source).toContain("import { useAIChatPanelResize } from './ai/useAIChatPanelResize';"); + expect(planContextSource).toContain('export const useAIChatPlanContexts'); + expect(planContextSource).toContain('pendingJVMPlanContextRef'); + expect(autoContextSource).toContain('export const useAIChatAutoContext'); + expect(autoContextSource).toContain('DBShowCreateTable'); + expect(resizeSource).toContain('export const useAIChatPanelResize'); + expect(resizeSource).toContain('document.body.style.pointerEvents = \'none\''); + }); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 0cbc25e..2fd84fd 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import { useStore, loadAISessionsFromBackend, loadAISessionFromBackend } from '../store'; +import { useStore } from '../store'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import type { AIChatMessage, @@ -44,6 +44,10 @@ import { dispatchAIChatPayload } from './ai/aiChatPayloadDispatch'; import { buildAIChatReadinessSnapshot } from './ai/aiChatReadiness'; import { buildAISystemContextMessages } from './ai/aiSystemContextMessages'; import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources'; +import { useAIChatAutoContext } from './ai/useAIChatAutoContext'; +import { useAIChatPanelResize } from './ai/useAIChatPanelResize'; +import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts'; +import { useAIChatSessionState } from './ai/useAIChatSessionState'; interface AIChatPanelProps { width?: number; @@ -64,8 +68,6 @@ export const AIChatPanel: React.FC = ({ const [draftImages, setDraftImages] = useState([]); const [sending, setSending] = useState(false); const [showScrollBottom, setShowScrollBottom] = useState(false); - const [panelWidth, setPanelWidth] = useState(width); - const [isResizing, setIsResizing] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat'); const { @@ -85,20 +87,15 @@ export const AIChatPanel: React.FC = ({ const messagesEndRef = useRef(null); const textareaRef = useRef(null); - const resizeStartX = useRef(0); - const resizeStartWidth = useRef(0); const toolCallRoundRef = useRef(0); // 连续失败轮次计数 const totalToolRoundRef = useRef(0); // 全局工具调用总轮次计数(防止无限循环) const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数 - const panelRef = useRef(null); // 面板 DOM ref,用于拖拽时直接操作宽度 - const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染) - const pendingJVMPlanContextRef = useRef(undefined); - const pendingJVMDiagnosticPlanContextRef = useRef(undefined); - - useEffect(() => { - setPanelWidth(width); - dragWidthRef.current = width; - }, [width]); + const { + getCurrentJVMPlanContext, + getCurrentJVMDiagnosticPlanContext, + pendingJVMPlanContextRef, + pendingJVMDiagnosticPlanContextRef, + } = useAIChatPlanContexts(); const aiChatHistory = useStore(state => state.aiChatHistory); const aiActiveSessionId = useStore(state => state.aiActiveSessionId); @@ -116,11 +113,22 @@ export const AIChatPanel: React.FC = ({ const tabs = useStore(state => state.tabs); const activeTabId = useStore(state => state.activeTabId); const sqlLogs = useStore(state => state.sqlLogs); - const aiChatSessions = useStore(state => state.aiChatSessions); const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId); const aiPanelVisible = useStore(state => state.aiPanelVisible); const isV2Ui = appearance.uiVersion === 'v2'; const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); + const { + ghostRef, + handleResizeStart, + isResizing, + panelRect, + panelRef, + panelWidth, + } = useAIChatPanelResize({ + width, + isV2Ui, + onWidthChange, + }); const availableTools = useMemo( () => buildAvailableAIChatTools(mcpTools), [mcpTools], @@ -130,111 +138,17 @@ export const AIChatPanel: React.FC = ({ 'sendAIChatMessage', activeShortcutPlatform, )); - const orderedAISessions = useMemo( - () => [...aiChatSessions].sort((left, right) => right.updatedAt - left.updatedAt), - [aiChatSessions], - ); + const { sid, messages, orderedAISessions } = useAIChatSessionState({ + aiActiveSessionId, + aiPanelVisible, + createNewAISession, + }); - const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => { - const state = useStore.getState(); - const activeTab = state.tabs.find(t => t.id === state.activeTabId); - if (!activeTab || activeTab.type !== 'jvm-resource') { - return undefined; - } - - const activeConnection = state.connections.find(c => c.id === activeTab.connectionId); - if (activeConnection?.config?.type !== 'jvm') { - return undefined; - } - - const resourcePath = String(activeTab.resourcePath || '').trim(); - if (!resourcePath) { - return undefined; - } - - return { - tabId: activeTab.id, - connectionId: activeTab.connectionId, - providerMode: (activeTab.providerMode || activeConnection.config.jvm?.preferredMode || 'jmx') as JVMAIPlanContext['providerMode'], - resourcePath, - }; - }, []); - - const getCurrentJVMDiagnosticPlanContext = useCallback((): JVMDiagnosticPlanContext | undefined => { - const state = useStore.getState(); - const activeTab = state.tabs.find(t => t.id === state.activeTabId); - if (!activeTab || activeTab.type !== 'jvm-diagnostic') { - return undefined; - } - - const activeConnection = state.connections.find(c => c.id === activeTab.connectionId); - if (activeConnection?.config?.type !== 'jvm') { - return undefined; - } - - return { - tabId: activeTab.id, - connectionId: activeTab.connectionId, - transport: activeConnection.config.jvm?.diagnostic?.transport || 'agent-bridge', - }; - }, []); - - // Auto-Context Injection Hook - useEffect(() => { - if (!aiPanelVisible) return; - const activeTab = tabs.find(t => t.id === activeTabId); - if (activeTab && (activeTab.type === 'table' || activeTab.type === 'design')) { - const { connectionId, dbName, tableName } = activeTab; - if (connectionId && dbName && tableName) { - const connKey = `${connectionId}:${dbName}`; - const currentContexts = useStore.getState().aiContexts[connKey] || []; - if (!currentContexts.find(c => c.dbName === dbName && c.tableName === tableName)) { - const conn = useStore.getState().connections.find(c => c.id === connectionId); - if (conn) { - import('../../wailsjs/go/app/App').then(({ DBShowCreateTable }) => { - DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName).then(res => { - if (res.success && res.data) { - let createSql = ''; - if (typeof res.data === 'string') createSql = res.data; - else if (Array.isArray(res.data) && res.data.length > 0) { - const row = res.data[0]; - createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string; - } - if (createSql) { - useStore.getState().addAIContext(connKey, { dbName: dbName, tableName, ddl: createSql }); - } - } - }); - }).catch(err => console.error("Failed to auto-fetch table context", err)); - } - } - } - } - }, [aiPanelVisible, activeTabId, tabs]); - - useEffect(() => { - if (!aiActiveSessionId) { - createNewAISession(); - } - }, [aiActiveSessionId, createNewAISession]); - - const sid = aiActiveSessionId || 'session-fallback'; - - // 面板首次可见时从后端加载会话列表 - const sessionsLoadedRef = useRef(false); - useEffect(() => { - if (!aiPanelVisible || sessionsLoadedRef.current) return; - sessionsLoadedRef.current = true; - loadAISessionsFromBackend(); - }, [aiPanelVisible]); - - // 切换会话时按需从后端加载消息 - useEffect(() => { - if (sid && sid !== 'session-fallback') { - loadAISessionFromBackend(sid); - } - }, [sid]); - const messages = aiChatHistory[sid] || []; + useAIChatAutoContext({ + aiPanelVisible, + activeTabId, + tabs, + }); const getConnectionName = useCallback(() => { let connectionId = activeContext?.connectionId; @@ -732,74 +646,6 @@ export const AIChatPanel: React.FC = ({ setSending(false); }, [sid]); - const ghostRef = useRef(null); - const panelRect = useRef<{top: number, bottom: number, left: number} | null>(null); - - const handleResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setIsResizing(true); - resizeStartX.current = e.clientX; - resizeStartWidth.current = panelWidth; - dragWidthRef.current = panelWidth; - if (panelRef.current) { - const rect = panelRef.current.getBoundingClientRect(); - panelRect.current = { - top: rect.top, - bottom: window.innerHeight - rect.bottom, - left: rect.left - }; - } - }, [panelWidth]); - - useEffect(() => { - if (!isResizing) return; - let animationFrameId: number; - const handleMouseMove = (e: MouseEvent) => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - animationFrameId = requestAnimationFrame(() => { - const delta = resizeStartX.current - e.clientX; - const minWidth = isV2Ui ? 300 : 280; - const maxWidth = isV2Ui ? 520 : 700; - const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, minWidth), maxWidth); - dragWidthRef.current = newWidth; - - // 仅更新 ghost 虚线位置,通过绝对定位规避重排 - if (ghostRef.current && panelRect.current) { - const actualDelta = newWidth - resizeStartWidth.current; - ghostRef.current.style.left = `${panelRect.current.left - actualDelta}px`; - } - }); - }; - const handleMouseUp = () => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - setIsResizing(false); - // 拖拽结束时才提交最终宽度到 React state 和外层回调 - setPanelWidth(dragWidthRef.current); - onWidthChange?.(dragWidthRef.current); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - // 拖拽期间关闭指针事件以避免下方 Monaco Editor 捕获 hover 或重绘,极大提升性能 - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - document.body.style.pointerEvents = 'none'; // 关键性能优化 - - return () => { - if (animationFrameId) cancelAnimationFrame(animationFrameId); - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.body.style.pointerEvents = ''; - }; - }, [isResizing, isV2Ui, onWidthChange]); - const { inferredConnectionId, inferredDbName } = useMemo( () => inferAIChatConnectionContext({ activeConnectionId: activeContext?.connectionId, @@ -814,17 +660,24 @@ export const AIChatPanel: React.FC = ({ 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); + const renderErrorPayload = { + messageId: msg.id, + role: msg.role, + contentPreview: String(msg.content || '').slice(0, 240), + message: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + recordedAt: Date.now(), + }; 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, - }; + (window as any).__gonaviLastAIMessageRenderError = renderErrorPayload; } + (globalThis as any).__gonaviLastAIMessageRenderError = renderErrorPayload; }, []); + const currentSessionTitle = useMemo( + () => orderedAISessions.find((session) => session.id === sid)?.title || '新对话', + [orderedAISessions, sid], + ); const activeConnectionConfig = useMemo(() => { if (!inferredConnectionId) return undefined; const connection = connections.find(c => c.id === inferredConnectionId); @@ -899,7 +752,7 @@ export const AIChatPanel: React.FC = ({ onSettingsClick={handleOpenSettingsFromPanel} onClose={onClose} messages={messages} - sessionTitle={useStore.getState().aiChatSessions.find(s => s.id === sid)?.title || '新对话'} + sessionTitle={currentSessionTitle} activeMode={effectivePanelMode} onModeChange={(mode) => { if (!isV2Ui) return; diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index a82b34f..a7b5e0b 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -66,6 +66,8 @@ describe('AIBuiltinToolsCatalog', () => { expect(markup).toContain('inspect_recent_sql_activity'); expect(markup).toContain('排查应用日志'); expect(markup).toContain('inspect_app_logs'); + expect(markup).toContain('排查 AI 气泡渲染异常'); + expect(markup).toContain('inspect_ai_last_render_error'); expect(markup).toContain('复用历史 SQL'); expect(markup).toContain('inspect_saved_queries'); expect(markup).toContain('回看 AI 历史对话'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index a65df2f..c596d7c 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -140,6 +140,11 @@ const BUILTIN_TOOL_FLOWS = [ steps: 'inspect_app_logs → inspect_mcp_setup / inspect_saved_connections / inspect_current_connection', description: '适合先回看 gonavi.log 尾部的 ERROR/WARN,再结合 MCP、连接和当前数据源状态继续定位启动异常、连接失败或外部工具拉起问题。', }, + { + title: '排查 AI 气泡渲染异常', + steps: 'inspect_ai_last_render_error → inspect_active_tab / inspect_ai_runtime', + description: '适合用户反馈 AI 某条消息空白、气泡局部报错但整个面板没挂时,先拿到最近一次被隔离的渲染异常快照,再回到具体会话和运行时上下文继续缩小范围。', + }, { title: '复用历史 SQL', steps: 'inspect_saved_queries → get_columns / execute_sql', diff --git a/frontend/src/components/ai/aiLastRenderErrorInsights.ts b/frontend/src/components/ai/aiLastRenderErrorInsights.ts new file mode 100644 index 0000000..4b31f83 --- /dev/null +++ b/frontend/src/components/ai/aiLastRenderErrorInsights.ts @@ -0,0 +1,56 @@ +const DEFAULT_PREVIEW_LIMIT = 240; +const DEFAULT_STACK_LIMIT = 1200; + +const truncateText = (value: unknown, limit: number) => { + const text = String(value || ''); + if (!text) { + return ''; + } + return text.length > limit ? `${text.slice(0, limit)}...` : text; +}; + +const resolveGlobalRenderError = () => { + const globalRecord = globalThis as Record; + const direct = globalRecord.__gonaviLastAIMessageRenderError; + if (direct && typeof direct === 'object') { + return direct as Record; + } + + const rootWindow = globalRecord.window as Record | undefined; + const fromWindow = rootWindow?.__gonaviLastAIMessageRenderError; + if (fromWindow && typeof fromWindow === 'object') { + return fromWindow as Record; + } + + return null; +}; + +export const buildAILastRenderErrorSnapshot = () => { + const renderError = resolveGlobalRenderError(); + if (!renderError) { + return { + hasError: false, + summary: '当前还没有记录到 AI 消息渲染异常。', + nextActions: [ + '如果用户反馈 AI 某条消息空白、白块或只出现局部报错,再重新触发问题后读取这里。', + '如果是整块 AI 面板异常,再结合 inspect_ai_setup_health 和 inspect_app_logs 一起看。', + ], + }; + } + + return { + hasError: true, + summary: '已记录到最近一次 AI 消息渲染异常,可据此定位是哪条消息、哪段渲染逻辑和报错栈摘要。', + messageId: String(renderError.messageId || ''), + role: String(renderError.role || ''), + recordedAt: typeof renderError.recordedAt === 'number' ? renderError.recordedAt : null, + contentPreview: truncateText(renderError.contentPreview, DEFAULT_PREVIEW_LIMIT), + errorMessage: truncateText(renderError.message, DEFAULT_PREVIEW_LIMIT), + stackPreview: truncateText(renderError.stack, DEFAULT_STACK_LIMIT), + componentStackPreview: truncateText(renderError.componentStack, DEFAULT_STACK_LIMIT), + nextActions: [ + '先按 messageId 和 contentPreview 对照当前会话,确认是哪条气泡触发的渲染异常。', + '如果需要继续缩小范围,再结合最近一次用户输入、工具结果和相关组件代码排查。', + ], + }; +}; diff --git a/frontend/src/components/ai/aiLocalToolExecutor.lastRenderError.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.lastRenderError.test.ts new file mode 100644 index 0000000..02fb953 --- /dev/null +++ b/frontend/src/components/ai/aiLocalToolExecutor.lastRenderError.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { AIToolCall } from '../../types'; +import { executeLocalAIToolCall } from './aiLocalToolExecutor'; + +const buildToolCall = ( + name: string, + args: Record, +): AIToolCall => ({ + id: `call-${name}`, + type: 'function', + function: { + name, + arguments: JSON.stringify(args), + }, +}); + +describe('aiLocalToolExecutor inspect_ai_last_render_error', () => { + afterEach(() => { + delete (globalThis as Record).__gonaviLastAIMessageRenderError; + }); + + it('returns the last isolated ai message render error so the model can diagnose blank bubbles from real frontend evidence', async () => { + (globalThis as Record).__gonaviLastAIMessageRenderError = { + messageId: 'msg-1', + role: 'assistant', + contentPreview: '这是一条触发渲染异常的 AI 回复预览', + message: 'Cannot read properties of undefined', + stack: 'TypeError: Cannot read properties of undefined\n at Bubble.tsx:12:3', + componentStack: '\n at AIMessageBubble\n at AIChatPanelConversationView', + recordedAt: 1780700000000, + }; + + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_last_render_error', {}), + connections: [], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"hasError":true'); + expect(result.content).toContain('"messageId":"msg-1"'); + expect(result.content).toContain('"role":"assistant"'); + expect(result.content).toContain('Cannot read properties of undefined'); + expect(result.content).toContain('AIMessageBubble'); + }); + + it('returns an empty snapshot when no render failure has been recorded yet', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_last_render_error', {}), + connections: [], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"hasError":false'); + expect(result.content).toContain('当前还没有记录到 AI 消息渲染异常'); + }); +}); diff --git a/frontend/src/components/ai/aiSlashCommands.test.ts b/frontend/src/components/ai/aiSlashCommands.test.ts index 31c59db..d8b8738 100644 --- a/frontend/src/components/ai/aiSlashCommands.test.ts +++ b/frontend/src/components/ai/aiSlashCommands.test.ts @@ -16,6 +16,7 @@ describe('aiSlashCommands', () => { expect(commands.some((command) => command.cmd === '/mcpadd')).toBe(true); expect(commands.some((command) => command.cmd === '/shortcuts')).toBe(true); expect(commands.some((command) => command.cmd === '/applog')).toBe(true); + expect(commands.some((command) => command.cmd === '/airender')).toBe(true); }); it('supports filtering by chinese keywords in addition to command prefix', () => { @@ -35,6 +36,11 @@ describe('aiSlashCommands', () => { expect(filterAISlashCommands('/app').map((command) => command.cmd)).toContain('/applog'); }); + it('supports filtering ai-render diagnostics by chinese keyword and command prefix', () => { + expect(filterAISlashCommands('气泡空白').map((command) => command.cmd)).toContain('/airender'); + expect(filterAISlashCommands('/air').map((command) => command.cmd)).toContain('/airender'); + }); + it('groups commands by configured category order', () => { const groups = groupAISlashCommands(filterAISlashCommands('/')); diff --git a/frontend/src/components/ai/aiSlashCommands.ts b/frontend/src/components/ai/aiSlashCommands.ts index 382ff28..44fce4c 100644 --- a/frontend/src/components/ai/aiSlashCommands.ts +++ b/frontend/src/components/ai/aiSlashCommands.ts @@ -52,6 +52,7 @@ export const DEFAULT_AI_SLASH_COMMANDS: AISlashCommandDefinition[] = [ { cmd: '/mcpadd', label: '🧭 新增 MCP 指引', desc: '查看 command、args、env 和模板怎么填', prompt: '请先调用 inspect_mcp_authoring_guide,再结合 inspect_mcp_setup,告诉我新增 GoNavi MCP 服务时 command、args、env、timeout 应该怎么填,以及最接近的模板应该选哪个。', category: 'diagnose', featured: true, keywords: ['mcp新增', 'command', 'args', 'env', '模板'] }, { cmd: '/shortcuts', label: '⌨️ 快捷键探针', desc: '读取当前 Win/Mac 快捷键配置', prompt: '请先调用 inspect_shortcuts,告诉我当前 GoNavi 的快捷键配置,尤其是执行 SQL、切换结果区、打开 AI 面板和 AI 发送消息这些动作在当前平台和另一平台分别怎么按,是否改过默认值。', category: 'diagnose', keywords: ['快捷键', 'shortcuts', '结果区', 'mac', 'windows'] }, { cmd: '/applog', label: '🪵 应用日志', desc: '回看最近 GoNavi 应用日志', prompt: '请先调用 inspect_app_logs,帮我看最近 GoNavi 应用日志里的错误和警告;如果我提到连接失败、MCP 拉起失败、启动异常或 gonavi.log,就优先结合关键词继续筛。', category: 'diagnose', keywords: ['日志', 'gonavi.log', 'mcp报错', '连接失败', '启动异常'] }, + { cmd: '/airender', label: '🧯 AI 渲染异常', desc: '读取最近一次 AI 消息渲染失败记录', prompt: '请先调用 inspect_ai_last_render_error,告诉我最近一次 AI 消息渲染失败记录里是哪条消息、报错摘要是什么,以及下一步该怎么排查。', category: 'diagnose', keywords: ['渲染失败', '气泡空白', 'ai消息', 'render', '白块'] }, { cmd: '/safety', label: '🛡️ 查看写入安全', desc: '确认只读/写入边界和 allowMutating', prompt: '请先调用 inspect_ai_safety,告诉我当前 AI 和 GoNavi MCP 的写入边界、是否只读,以及 execute_sql 是否需要 allowMutating。', category: 'diagnose', keywords: ['安全', '只读', 'allowmutating', 'ddl', 'dml'] }, { cmd: '/activity', label: '🕘 最近 SQL 活动', desc: '总结最近执行、报错和热点', prompt: '请先调用 inspect_recent_sql_activity,帮我总结最近 SQL 活动、错误热点和主要读写类型。', category: 'diagnose', keywords: ['activity', 'sql日志', '最近执行', '报错'] }, ]; diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index b2a38d4..b1f7817 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -33,6 +33,7 @@ import { buildWorkspaceTabsSnapshot, } from './aiWorkspaceInsights'; import { buildShortcutSnapshot } from './aiShortcutInsights'; +import { buildAILastRenderErrorSnapshot } from './aiLastRenderErrorInsights'; import { executeAIConfigSnapshotToolCall } from './aiSnapshotInspectionAIConfigToolExecutor'; import type { AISnapshotInspectionRuntime, @@ -268,6 +269,11 @@ export async function executeSnapshotInspectionToolCall( success: true, }; } + case 'inspect_ai_last_render_error': + return { + content: JSON.stringify(buildAILastRenderErrorSnapshot()), + success: true, + }; case 'inspect_saved_queries': return { content: JSON.stringify(buildSavedQueriesSnapshot({ @@ -329,6 +335,7 @@ export async function executeSnapshotInspectionToolCall( inspect_recent_sql_logs: '获取最近 SQL 日志失败', inspect_recent_sql_activity: '汇总最近 SQL 活动失败', inspect_app_logs: '读取 GoNavi 应用日志失败', + inspect_ai_last_render_error: '读取最近一次 AI 渲染异常失败', inspect_saved_queries: '读取已保存查询失败', inspect_sql_snippets: '读取 SQL 片段失败', inspect_shortcuts: '读取快捷键配置失败', diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index de8e5d7..6f06ecd 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.test.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.test.ts @@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => { connections: [connections[0]], tabs: [], activeTabId: null, - availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_app_logs', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], + availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], skills, userPromptSettings, }); @@ -91,6 +91,7 @@ describe('buildAISystemContextMessages', () => { expect(joined).toContain('inspect_external_sql_file'); expect(joined).toContain('inspect_recent_sql_activity'); expect(joined).toContain('inspect_app_logs 读取真实应用日志尾部'); + expect(joined).toContain('inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录'); expect(joined).toContain('inspect_saved_queries'); expect(joined).toContain('inspect_ai_sessions'); expect(joined).toContain('inspect_sql_snippets'); diff --git a/frontend/src/components/ai/aiSystemContextMessages.ts b/frontend/src/components/ai/aiSystemContextMessages.ts index 6671948..0056641 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.ts @@ -225,6 +225,19 @@ const appendAppLogInspectionGuidance = ( }); }; +const appendAILastRenderErrorInspectionGuidance = ( + messages: AISystemContextMessage[], + availableToolNames: string[], +) => { + if (!availableToolNames.includes('inspect_ai_last_render_error')) { + return; + } + messages.push({ + role: 'system', + content: '如果用户提到“AI 某条消息空白了”“某个气泡渲染失败”“消息块局部报错但面板没全挂”,优先调用 inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录,不要只凭截图现象猜测。', + }); +}; + const appendConnectionCapabilityInspectionGuidance = ( messages: AISystemContextMessage[], availableToolNames: string[], @@ -466,6 +479,7 @@ SELECT * FROM users WHERE status = 1; appendAIGuidanceInspectionGuidance(systemMessages, availableToolNames); appendShortcutInspectionGuidance(systemMessages, availableToolNames); appendAppLogInspectionGuidance(systemMessages, availableToolNames); + appendAILastRenderErrorInspectionGuidance(systemMessages, availableToolNames); if (availableToolNames.includes('inspect_current_connection')) { systemMessages.push({ role: 'system', diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index 58d32ef..111bc77 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -52,6 +52,7 @@ const TOOL_ACTION_LABELS: Record = { inspect_recent_sql_logs: '回看最近 SQL 执行日志', inspect_recent_sql_activity: '总结最近 SQL 活动', inspect_app_logs: '回看 GoNavi 应用日志', + inspect_ai_last_render_error: '读取最近一次 AI 渲染异常', inspect_saved_queries: '检索本地已保存查询', inspect_sql_snippets: '读取 SQL 片段模板', inspect_shortcuts: '读取当前快捷键配置', diff --git a/frontend/src/components/ai/useAIChatAutoContext.ts b/frontend/src/components/ai/useAIChatAutoContext.ts new file mode 100644 index 0000000..a20d581 --- /dev/null +++ b/frontend/src/components/ai/useAIChatAutoContext.ts @@ -0,0 +1,77 @@ +import { useEffect } from 'react'; + +import { useStore } from '../../store'; +import type { TabData } from '../../types'; +import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; + +interface UseAIChatAutoContextOptions { + aiPanelVisible: boolean; + activeTabId: string | null; + tabs: TabData[]; +} + +export const useAIChatAutoContext = ({ + aiPanelVisible, + activeTabId, + tabs, +}: UseAIChatAutoContextOptions) => { + useEffect(() => { + if (!aiPanelVisible) { + return; + } + + const activeTab = tabs.find((tab) => tab.id === activeTabId); + if (!activeTab || (activeTab.type !== 'table' && activeTab.type !== 'design')) { + return; + } + + const { connectionId, dbName, tableName } = activeTab; + if (!connectionId || !dbName || !tableName) { + return; + } + + const connKey = `${connectionId}:${dbName}`; + const currentContexts = useStore.getState().aiContexts[connKey] || []; + if (currentContexts.find((context) => context.dbName === dbName && context.tableName === tableName)) { + return; + } + + const connection = useStore.getState().connections.find((item) => item.id === connectionId); + if (!connection) { + return; + } + + void import('../../../wailsjs/go/app/App') + .then(({ DBShowCreateTable }) => + DBShowCreateTable(buildRpcConnectionConfig(connection.config) as any, dbName, tableName) + .then((result) => { + if (!result.success || !result.data) { + return; + } + + let createSql = ''; + if (typeof result.data === 'string') { + createSql = result.data; + } else if (Array.isArray(result.data) && result.data.length > 0) { + const row = result.data[0]; + createSql = ( + Object.values(row).find( + (value) => + typeof value === 'string' && + (value.toUpperCase().includes('CREATE TABLE') || value.toUpperCase().includes('CREATE')), + ) || + Object.values(row)[1] || + Object.values(row)[0] + ) as string; + } + + if (!createSql) { + return; + } + + useStore.getState().addAIContext(connKey, { dbName, tableName, ddl: createSql }); + }), + ) + .catch((error) => console.error('Failed to auto-fetch table context', error)); + }, [activeTabId, aiPanelVisible, tabs]); +}; diff --git a/frontend/src/components/ai/useAIChatPanelResize.ts b/frontend/src/components/ai/useAIChatPanelResize.ts new file mode 100644 index 0000000..c2139b9 --- /dev/null +++ b/frontend/src/components/ai/useAIChatPanelResize.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'; + +interface UseAIChatPanelResizeOptions { + width: number; + isV2Ui: boolean; + onWidthChange?: (width: number) => void; +} + +export const useAIChatPanelResize = ({ + width, + isV2Ui, + onWidthChange, +}: UseAIChatPanelResizeOptions) => { + const [panelWidth, setPanelWidth] = useState(width); + const [isResizing, setIsResizing] = useState(false); + + const panelRef = useRef(null); + const ghostRef = useRef(null); + const panelRect = useRef<{ top: number; bottom: number; left: number } | null>(null); + const resizeStartX = useRef(0); + const resizeStartWidth = useRef(0); + const dragWidthRef = useRef(width); + + useEffect(() => { + setPanelWidth(width); + dragWidthRef.current = width; + }, [width]); + + const handleResizeStart = useCallback((event: ReactMouseEvent) => { + event.preventDefault(); + setIsResizing(true); + resizeStartX.current = event.clientX; + resizeStartWidth.current = panelWidth; + dragWidthRef.current = panelWidth; + if (!panelRef.current) { + return; + } + const rect = panelRef.current.getBoundingClientRect(); + panelRect.current = { + top: rect.top, + bottom: window.innerHeight - rect.bottom, + left: rect.left, + }; + }, [panelWidth]); + + useEffect(() => { + if (!isResizing) { + return; + } + + let animationFrameId = 0; + const handleMouseMove = (event: MouseEvent) => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + animationFrameId = requestAnimationFrame(() => { + const delta = resizeStartX.current - event.clientX; + const minWidth = isV2Ui ? 300 : 280; + const maxWidth = isV2Ui ? 520 : 700; + const nextWidth = Math.min(Math.max(resizeStartWidth.current + delta, minWidth), maxWidth); + dragWidthRef.current = nextWidth; + + if (!ghostRef.current || !panelRect.current) { + return; + } + const actualDelta = nextWidth - resizeStartWidth.current; + ghostRef.current.style.left = `${panelRect.current.left - actualDelta}px`; + }); + }; + + const handleMouseUp = () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + setIsResizing(false); + setPanelWidth(dragWidthRef.current); + onWidthChange?.(dragWidthRef.current); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.body.style.pointerEvents = 'none'; + + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.body.style.pointerEvents = ''; + }; + }, [isResizing, isV2Ui, onWidthChange]); + + return { + ghostRef, + handleResizeStart, + isResizing, + panelRect, + panelRef, + panelWidth, + }; +}; diff --git a/frontend/src/components/ai/useAIChatPlanContexts.ts b/frontend/src/components/ai/useAIChatPlanContexts.ts new file mode 100644 index 0000000..ce26150 --- /dev/null +++ b/frontend/src/components/ai/useAIChatPlanContexts.ts @@ -0,0 +1,60 @@ +import { useCallback, useRef } from 'react'; + +import { useStore } from '../../store'; +import type { JVMAIPlanContext, JVMDiagnosticPlanContext } from '../../types'; + +export const useAIChatPlanContexts = () => { + const pendingJVMPlanContextRef = useRef(undefined); + const pendingJVMDiagnosticPlanContextRef = useRef(undefined); + + const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => { + const state = useStore.getState(); + const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); + if (!activeTab || activeTab.type !== 'jvm-resource') { + return undefined; + } + + const activeConnection = state.connections.find((connection) => connection.id === activeTab.connectionId); + if (activeConnection?.config?.type !== 'jvm') { + return undefined; + } + + const resourcePath = String(activeTab.resourcePath || '').trim(); + if (!resourcePath) { + return undefined; + } + + return { + tabId: activeTab.id, + connectionId: activeTab.connectionId, + providerMode: (activeTab.providerMode || activeConnection.config.jvm?.preferredMode || 'jmx') as JVMAIPlanContext['providerMode'], + resourcePath, + }; + }, []); + + const getCurrentJVMDiagnosticPlanContext = useCallback((): JVMDiagnosticPlanContext | undefined => { + const state = useStore.getState(); + const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); + if (!activeTab || activeTab.type !== 'jvm-diagnostic') { + return undefined; + } + + const activeConnection = state.connections.find((connection) => connection.id === activeTab.connectionId); + if (activeConnection?.config?.type !== 'jvm') { + return undefined; + } + + return { + tabId: activeTab.id, + connectionId: activeTab.connectionId, + transport: activeConnection.config.jvm?.diagnostic?.transport || 'agent-bridge', + }; + }, []); + + return { + getCurrentJVMPlanContext, + getCurrentJVMDiagnosticPlanContext, + pendingJVMPlanContextRef, + pendingJVMDiagnosticPlanContextRef, + }; +}; diff --git a/frontend/src/components/ai/useAIChatSessionState.ts b/frontend/src/components/ai/useAIChatSessionState.ts new file mode 100644 index 0000000..fa514a5 --- /dev/null +++ b/frontend/src/components/ai/useAIChatSessionState.ts @@ -0,0 +1,57 @@ +import { useEffect, useMemo, useRef } from 'react'; + +import { + loadAISessionFromBackend, + loadAISessionsFromBackend, + useStore, +} from '../../store'; + +interface UseAIChatSessionStateOptions { + aiActiveSessionId: string | null; + aiPanelVisible: boolean; + createNewAISession: () => void; +} + +export const useAIChatSessionState = ({ + aiActiveSessionId, + aiPanelVisible, + createNewAISession, +}: UseAIChatSessionStateOptions) => { + const aiChatHistory = useStore((state) => state.aiChatHistory); + const aiChatSessions = useStore((state) => state.aiChatSessions); + + useEffect(() => { + if (!aiActiveSessionId) { + createNewAISession(); + } + }, [aiActiveSessionId, createNewAISession]); + + const sid = aiActiveSessionId || 'session-fallback'; + const messages = aiChatHistory[sid] || []; + + const sessionsLoadedRef = useRef(false); + useEffect(() => { + if (!aiPanelVisible || sessionsLoadedRef.current) { + return; + } + sessionsLoadedRef.current = true; + loadAISessionsFromBackend(); + }, [aiPanelVisible]); + + useEffect(() => { + if (sid && sid !== 'session-fallback') { + loadAISessionFromBackend(sid); + } + }, [sid]); + + const orderedAISessions = useMemo( + () => [...aiChatSessions].sort((left, right) => right.updatedAt - left.updatedAt), + [aiChatSessions], + ); + + return { + sid, + messages, + orderedAISessions, + }; +}; diff --git a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts index 39959bd..2d41f02 100644 --- a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts @@ -401,6 +401,23 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_ai_last_render_error", + icon: "🧯", + desc: "查看最近一次 AI 消息渲染异常记录", + detail: + "返回最近一次被前端隔离下来的 AI 消息渲染异常,包括是哪条消息、消息内容预览、错误摘要和组件栈摘要。适合用户提到“AI 某条回复空白了”“某个气泡渲染失败”“消息块报错但面板没全挂”时,先读这份真实前端异常快照。", + params: "无参数", + tool: { + type: "function", + function: { + name: "inspect_ai_last_render_error", + description: + "读取最近一次 AI 消息渲染异常的本地快照,包括消息 ID、角色、内容预览、错误摘要、组件栈摘要和下一步排查建议。适用于用户提到 AI 消息空白、某条回复渲染失败、气泡局部报错但面板仍然存活时,先读取真实前端异常记录,不要只凭现象猜测。", + parameters: { type: "object", properties: {} }, + }, + }, + }, { name: "inspect_saved_queries", icon: "💾", diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index d1f1f99..933ebf4 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -108,9 +108,17 @@ describe('aiToolRegistry', () => { expect(info?.tool.function.description).toContain('gonavi.log'); }); + it('registers the ai-render-error inspector as a builtin tool', () => { + const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_last_render_error'); + expect(info).toBeTruthy(); + expect(info?.desc).toContain('渲染异常'); + expect(info?.tool.function.description).toContain('消息渲染异常'); + }); + it('registers the recent-sql-activity, saved-query, and sql-snippet inspectors as builtin tools', () => { const recentActivityTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_sql_activity'); const appLogTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_app_logs'); + const renderErrorTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_last_render_error'); const savedQueryTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_saved_queries'); const aiSessionsTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_sessions'); const snippetTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_snippets'); @@ -119,6 +127,8 @@ describe('aiToolRegistry', () => { expect(recentActivityTool?.tool.function.description).toContain('最近 SQL 活动'); expect(appLogTool?.desc).toContain('GoNavi 应用日志'); expect(appLogTool?.tool.function.description).toContain('应用日志'); + expect(renderErrorTool?.desc).toContain('渲染异常记录'); + expect(renderErrorTool?.tool.function.description).toContain('气泡局部报错'); expect(savedQueryTool?.desc).toContain('已保存的 SQL 查询'); expect(savedQueryTool?.tool.function.description).toContain('历史查询'); expect(aiSessionsTool?.desc).toContain('AI 历史会话'); @@ -158,6 +168,7 @@ describe('aiToolRegistry', () => { expect(tools.some((item) => item.function.name === 'inspect_external_sql_file')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_recent_sql_activity')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_app_logs')).toBe(true); + expect(tools.some((item) => item.function.name === 'inspect_ai_last_render_error')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_saved_queries')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_sessions')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_sql_snippets')).toBe(true);