mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
✨ feat(ai-chat): 新增渲染异常探针并拆分聊天面板逻辑
This commit is contained in:
@@ -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\'');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 历史对话');
|
||||
|
||||
@@ -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',
|
||||
|
||||
56
frontend/src/components/ai/aiLastRenderErrorInsights.ts
Normal file
56
frontend/src/components/ai/aiLastRenderErrorInsights.ts
Normal 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 对照当前会话,确认是哪条气泡触发的渲染异常。',
|
||||
'如果需要继续缩小范围,再结合最近一次用户输入、工具结果和相关组件代码排查。',
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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 消息渲染异常');
|
||||
});
|
||||
});
|
||||
@@ -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('/'));
|
||||
|
||||
|
||||
@@ -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日志', '最近执行', '报错'] },
|
||||
];
|
||||
|
||||
@@ -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: '读取快捷键配置失败',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '读取当前快捷键配置',
|
||||
|
||||
77
frontend/src/components/ai/useAIChatAutoContext.ts
Normal file
77
frontend/src/components/ai/useAIChatAutoContext.ts
Normal 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]);
|
||||
};
|
||||
106
frontend/src/components/ai/useAIChatPanelResize.ts
Normal file
106
frontend/src/components/ai/useAIChatPanelResize.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
60
frontend/src/components/ai/useAIChatPlanContexts.ts
Normal file
60
frontend/src/components/ai/useAIChatPlanContexts.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
57
frontend/src/components/ai/useAIChatSessionState.ts
Normal file
57
frontend/src/components/ai/useAIChatSessionState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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: "💾",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user