feat(ai-chat): 新增渲染异常探针并拆分聊天面板逻辑

This commit is contained in:
Syngnat
2026-06-09 08:29:02 +08:00
parent c081d23cc4
commit 86095b5bf1
18 changed files with 559 additions and 199 deletions

View File

@@ -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\'');
});
});

View File

@@ -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<AIChatPanelProps> = ({
const [draftImages, setDraftImages] = useState<string[]>([]);
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<AIChatPanelProps> = ({
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLDivElement>(null); // 面板 DOM ref用于拖拽时直接操作宽度
const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染)
const pendingJVMPlanContextRef = useRef<JVMAIPlanContext | undefined>(undefined);
const pendingJVMDiagnosticPlanContextRef = useRef<JVMDiagnosticPlanContext | undefined>(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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
'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<AIChatPanelProps> = ({
setSending(false);
}, [sid]);
const ghostRef = useRef<HTMLDivElement>(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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
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;

View File

@@ -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 历史对话');

View File

@@ -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',

View File

@@ -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<string, unknown>;
const direct = globalRecord.__gonaviLastAIMessageRenderError;
if (direct && typeof direct === 'object') {
return direct as Record<string, unknown>;
}
const rootWindow = globalRecord.window as Record<string, unknown> | undefined;
const fromWindow = rootWindow?.__gonaviLastAIMessageRenderError;
if (fromWindow && typeof fromWindow === 'object') {
return fromWindow as Record<string, unknown>;
}
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 对照当前会话,确认是哪条气泡触发的渲染异常。',
'如果需要继续缩小范围,再结合最近一次用户输入、工具结果和相关组件代码排查。',
],
};
};

View File

@@ -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<string, unknown>,
): AIToolCall => ({
id: `call-${name}`,
type: 'function',
function: {
name,
arguments: JSON.stringify(args),
},
});
describe('aiLocalToolExecutor inspect_ai_last_render_error', () => {
afterEach(() => {
delete (globalThis as Record<string, unknown>).__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<string, unknown>).__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 消息渲染异常');
});
});

View File

@@ -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('/'));

View File

@@ -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日志', '最近执行', '报错'] },
];

View File

@@ -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: '读取快捷键配置失败',

View File

@@ -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');

View File

@@ -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',

View File

@@ -52,6 +52,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
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: '读取当前快捷键配置',

View File

@@ -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]);
};

View File

@@ -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<HTMLDivElement>(null);
const ghostRef = useRef<HTMLDivElement>(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,
};
};

View File

@@ -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<JVMAIPlanContext | undefined>(undefined);
const pendingJVMDiagnosticPlanContextRef = useRef<JVMDiagnosticPlanContext | undefined>(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,
};
};

View File

@@ -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,
};
};

View File

@@ -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: "💾",

View File

@@ -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);