mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 10:29:52 +08:00
♻️ refactor(ai-chat): 拆分本地工具调用链
- 抽出 useAIChatLocalTools 承载工具执行、熔断和回灌模型逻辑 - 补齐重试消息的工具上下文依赖,避免配置变更后使用旧闭包 - 增加 hook 行为测试并同步 MCP 指南断言
This commit is contained in:
@@ -12,6 +12,7 @@ const resizeSource = readFileSync(new URL('./ai/useAIChatPanelResize.ts', import
|
||||
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 titleGeneratorSource = readFileSync(new URL('./ai/useAIChatSessionTitleGenerator.ts', import.meta.url), 'utf8');
|
||||
const localToolsSource = readFileSync(new URL('./ai/useAIChatLocalTools.ts', import.meta.url), 'utf8');
|
||||
const streamSubscriptionSource = readFileSync(new URL('./ai/useAIChatStreamSubscription.ts', import.meta.url), 'utf8');
|
||||
const inspectionGuidanceSource = readFileSync(new URL('./ai/aiSystemInspectionGuidance.ts', import.meta.url), 'utf8');
|
||||
const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8');
|
||||
@@ -41,7 +42,8 @@ describe('AIChatPanel message render isolation', () => {
|
||||
it('loads MCP tools and skills into the runtime tool chain', () => {
|
||||
expect(runtimeResourcesSource).toContain('AIListMCPTools');
|
||||
expect(runtimeResourcesSource).toContain('AIGetSkills');
|
||||
expect(source).toContain('executeLocalAIToolCall');
|
||||
expect(source).toContain("import { useAIChatLocalTools } from './ai/useAIChatLocalTools';");
|
||||
expect(localToolsSource).toContain('executeLocalAIToolCall');
|
||||
expect(systemContextSource).toContain('以下是当前启用的 Skill');
|
||||
expect(source).toContain('buildAvailableAIChatTools');
|
||||
});
|
||||
@@ -53,11 +55,11 @@ describe('AIChatPanel message render isolation', () => {
|
||||
expect(inspectionGuidanceSource).toContain('inspect_current_connection');
|
||||
expect(inspectionGuidanceSource).toContain('inspect_external_sql_directories');
|
||||
expect(inspectionGuidanceSource).toContain('inspect_external_sql_file');
|
||||
expect(source).toContain('tabs: useStore.getState().tabs');
|
||||
expect(source).toContain('activeTabId: useStore.getState().activeTabId');
|
||||
expect(source).toContain('externalSQLDirectories: useStore.getState().externalSQLDirectories');
|
||||
expect(source).toContain('toolContextMap: toolContextMapRef.current');
|
||||
expect(source).toContain('buildToolResultMessage');
|
||||
expect(localToolsSource).toContain('tabs: currentState.tabs');
|
||||
expect(localToolsSource).toContain('activeTabId: currentState.activeTabId');
|
||||
expect(localToolsSource).toContain('externalSQLDirectories: currentState.externalSQLDirectories');
|
||||
expect(localToolsSource).toContain('toolContextMap: toolContextMapRef.current');
|
||||
expect(localToolsSource).toContain('buildToolResultMessage');
|
||||
});
|
||||
|
||||
it('extracts chat runtime helpers so context compression and error cleanup stay out of the panel file', () => {
|
||||
@@ -65,11 +67,14 @@ describe('AIChatPanel message render isolation', () => {
|
||||
expect(source).toContain("import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';");
|
||||
expect(source).toContain('compressContextIfNeeded, getDynamicMaxContextChars');
|
||||
expect(source).toContain('useAIChatStreamSubscription({');
|
||||
expect(source).toContain('useAIChatLocalTools({');
|
||||
expect(runtimeSource).toContain('export const getDynamicMaxContextChars');
|
||||
expect(runtimeSource).toContain('export const compressContextIfNeeded');
|
||||
expect(runtimeSource).toContain('export const sanitizeErrorMsg');
|
||||
expect(payloadDispatchSource).toContain('export const dispatchAIChatPayload');
|
||||
expect(payloadDispatchSource).toContain('sanitizeErrorMsg');
|
||||
expect(localToolsSource).toContain('compressContextIfNeeded');
|
||||
expect(localToolsSource).toContain('dispatchAIChatPayload');
|
||||
expect(streamSubscriptionSource).toContain('EventsOn(eventName, handler);');
|
||||
expect(streamSubscriptionSource).toContain('请直接使用 function call 调用工具执行操作');
|
||||
expect(streamSubscriptionSource).toContain('executeLocalTools(existing.tool_calls!, doneAssistantId)');
|
||||
@@ -92,6 +97,7 @@ describe('AIChatPanel message render isolation', () => {
|
||||
expect(source).toContain("import { useAIChatAutoContext } from './ai/useAIChatAutoContext';");
|
||||
expect(source).toContain("import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator';");
|
||||
expect(source).toContain("import { useAIChatPanelResize } from './ai/useAIChatPanelResize';");
|
||||
expect(source).toContain("import { useAIChatLocalTools } from './ai/useAIChatLocalTools';");
|
||||
expect(planContextSource).toContain('export const useAIChatPlanContexts');
|
||||
expect(planContextSource).toContain('pendingJVMPlanContextRef');
|
||||
expect(autoContextSource).toContain('export const useAIChatAutoContext');
|
||||
@@ -100,5 +106,7 @@ describe('AIChatPanel message render isolation', () => {
|
||||
expect(titleGeneratorSource).toContain('Failed to auto-generate title');
|
||||
expect(resizeSource).toContain('export const useAIChatPanelResize');
|
||||
expect(resizeSource).toContain('document.body.style.pointerEvents = \'none\'');
|
||||
expect(localToolsSource).toContain('export const useAIChatLocalTools');
|
||||
expect(localToolsSource).toContain('MAX_TOOL_CALL_ROUNDS');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useStore } from '../store';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import type {
|
||||
AIChatMessage,
|
||||
AIToolCall,
|
||||
JVMAIPlanContext,
|
||||
JVMDiagnosticPlanContext,
|
||||
} from '../types';
|
||||
@@ -27,11 +26,6 @@ import { compressContextIfNeeded, getDynamicMaxContextChars } from '../utils/aiC
|
||||
import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts';
|
||||
import { isMacLikePlatform } from '../utils/appearance';
|
||||
import { buildAvailableAIChatTools } from '../utils/aiToolRegistry';
|
||||
import {
|
||||
buildToolResultMessage,
|
||||
executeLocalAIToolCall,
|
||||
type AIToolContextEntry,
|
||||
} from './ai/aiLocalToolExecutor';
|
||||
import {
|
||||
buildAIChatInlineHistorySessions,
|
||||
buildAIChatInsights,
|
||||
@@ -49,6 +43,7 @@ import { useAIChatPanelResize } from './ai/useAIChatPanelResize';
|
||||
import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts';
|
||||
import { useAIChatSessionState } from './ai/useAIChatSessionState';
|
||||
import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator';
|
||||
import { useAIChatLocalTools } from './ai/useAIChatLocalTools';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
@@ -88,8 +83,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const toolCallRoundRef = useRef(0); // 连续失败轮次计数
|
||||
const totalToolRoundRef = useRef(0); // 全局工具调用总轮次计数(防止无限循环)
|
||||
const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数
|
||||
const {
|
||||
getCurrentJVMPlanContext,
|
||||
@@ -219,6 +212,45 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}, [sid, truncateAIChatMessages, deleteAIChatMessage]);
|
||||
|
||||
const buildSystemContextMessages = useCallback((
|
||||
overrideJVMPlanContext?: JVMAIPlanContext,
|
||||
overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext,
|
||||
) => {
|
||||
const { activeContext, aiContexts, connections, tabs, activeTabId } = useStore.getState();
|
||||
return buildAISystemContextMessages({
|
||||
activeContext,
|
||||
aiContexts,
|
||||
connections,
|
||||
tabs,
|
||||
activeTabId,
|
||||
availableToolNames: availableTools.map((tool) => tool.function.name),
|
||||
skills,
|
||||
userPromptSettings,
|
||||
overrideJVMPlanContext,
|
||||
overrideJVMDiagnosticPlanContext,
|
||||
});
|
||||
}, [availableTools, skills, userPromptSettings]);
|
||||
|
||||
const {
|
||||
executeLocalTools,
|
||||
resetToolCallState,
|
||||
toolContextMapRef,
|
||||
} = useAIChatLocalTools({
|
||||
sid,
|
||||
activeProviderModel: activeProvider?.model,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
dynamicModels,
|
||||
mcpTools,
|
||||
nextMessageId: genId,
|
||||
pendingJVMPlanContextRef,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
setSending,
|
||||
skills,
|
||||
updateAIChatMessage,
|
||||
userPromptSettings,
|
||||
});
|
||||
|
||||
const handleRetryMessage = useCallback(async (msg: AIChatMessage) => {
|
||||
const historyLocal = useStore.getState().aiChatHistory[sid] || [];
|
||||
const aiIndex = historyLocal.findIndex(m => m.id === msg.id);
|
||||
@@ -236,9 +268,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const userMsg = historyLocal[lastUserMsgIndex];
|
||||
truncateAIChatMessages(sid, userMsg.id);
|
||||
|
||||
// 重置计数器(与 handleSend 保持一致)
|
||||
toolCallRoundRef.current = 0;
|
||||
totalToolRoundRef.current = 0;
|
||||
resetToolCallState();
|
||||
nudgeCountRef.current = 0;
|
||||
const retryJVMPlanContext = msg.jvmPlanContext || getCurrentJVMPlanContext();
|
||||
const retryJVMDiagnosticPlanContext =
|
||||
@@ -282,192 +312,15 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}
|
||||
}, [
|
||||
sid,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
truncateAIChatMessages,
|
||||
addAIChatMessage,
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
resetToolCallState,
|
||||
]);
|
||||
|
||||
const buildSystemContextMessages = useCallback((
|
||||
overrideJVMPlanContext?: JVMAIPlanContext,
|
||||
overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext,
|
||||
) => {
|
||||
const { activeContext, aiContexts, connections, tabs, activeTabId } = useStore.getState();
|
||||
return buildAISystemContextMessages({
|
||||
activeContext,
|
||||
aiContexts,
|
||||
connections,
|
||||
tabs,
|
||||
activeTabId,
|
||||
availableToolNames: availableTools.map((tool) => tool.function.name),
|
||||
skills,
|
||||
userPromptSettings,
|
||||
overrideJVMPlanContext,
|
||||
overrideJVMDiagnosticPlanContext,
|
||||
});
|
||||
}, [availableTools, skills, userPromptSettings]);
|
||||
|
||||
// 记录所有成功的 get_tables 调用结果,用于表级精确匹配
|
||||
const toolContextMapRef = useRef<Map<string, AIToolContextEntry>>(new Map());
|
||||
|
||||
const executeLocalTools = useCallback(async (toolCalls: AIToolCall[], currentAsstMsgId: string) => {
|
||||
const currentAsstMsg = (useStore.getState().aiChatHistory[sid] || []).find(m => m.id === currentAsstMsgId);
|
||||
const inheritedJVMPlanContext = currentAsstMsg?.jvmPlanContext || pendingJVMPlanContextRef.current;
|
||||
const inheritedJVMDiagnosticPlanContext =
|
||||
currentAsstMsg?.jvmDiagnosticPlanContext || pendingJVMDiagnosticPlanContextRef.current;
|
||||
pendingJVMPlanContextRef.current = inheritedJVMPlanContext;
|
||||
pendingJVMDiagnosticPlanContextRef.current = inheritedJVMDiagnosticPlanContext;
|
||||
|
||||
// 【全局轮次熔断】防止模型(如 DeepSeek)在已生成答案后仍无限循环调用工具
|
||||
const MAX_TOOL_CALL_ROUNDS = 15;
|
||||
totalToolRoundRef.current += 1;
|
||||
if (totalToolRoundRef.current > MAX_TOOL_CALL_ROUNDS) {
|
||||
updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' });
|
||||
useStore.getState().addAIChatMessage(sid, {
|
||||
id: genId(), role: 'assistant',
|
||||
content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`,
|
||||
timestamp: Date.now(),
|
||||
jvmPlanContext: inheritedJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
|
||||
});
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: AIChatMessage[] = [];
|
||||
const currentConnections = useStore.getState().connections;
|
||||
// 【串行逐条执行 + 实时写入 store】
|
||||
for (const tc of toolCalls) {
|
||||
const execution = await executeLocalAIToolCall({
|
||||
toolCall: tc,
|
||||
connections: currentConnections,
|
||||
activeContext: useStore.getState().activeContext,
|
||||
aiContexts: useStore.getState().aiContexts,
|
||||
aiChatHistory: useStore.getState().aiChatHistory,
|
||||
aiChatSessions: useStore.getState().aiChatSessions,
|
||||
activeSessionId: sid,
|
||||
tabs: useStore.getState().tabs,
|
||||
activeTabId: useStore.getState().activeTabId,
|
||||
mcpTools,
|
||||
toolContextMap: toolContextMapRef.current,
|
||||
sqlLogs: useStore.getState().sqlLogs,
|
||||
savedQueries: useStore.getState().savedQueries,
|
||||
sqlSnippets: useStore.getState().sqlSnippets,
|
||||
externalSQLDirectories: useStore.getState().externalSQLDirectories,
|
||||
skills,
|
||||
userPromptSettings,
|
||||
dynamicModels,
|
||||
});
|
||||
const toolResultMsg: AIChatMessage = buildToolResultMessage({
|
||||
id: genId(),
|
||||
timestamp: Date.now(),
|
||||
toolCall: tc,
|
||||
execution,
|
||||
});
|
||||
results.push(toolResultMsg);
|
||||
|
||||
// 【实时写入】每执行完一条立即写入 store,让 UI 能实时看到进度打勾
|
||||
useStore.getState().addAIChatMessage(sid, toolResultMsg);
|
||||
|
||||
// 延迟 150ms,给 UI 渲染时间,创造“逐个完成”的视觉节奏
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
// 智能熔断:只计连续失败轮次,成功则重置
|
||||
const anySuccess = results.some(r => r.success === true);
|
||||
if (anySuccess) {
|
||||
toolCallRoundRef.current = 0;
|
||||
} else {
|
||||
toolCallRoundRef.current += 1;
|
||||
if (toolCallRoundRef.current >= 3) {
|
||||
useStore.getState().addAIChatMessage(sid, {
|
||||
id: genId(), role: 'assistant',
|
||||
content: '⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。',
|
||||
timestamp: Date.now(),
|
||||
jvmPlanContext: inheritedJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
|
||||
});
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
// 【过渡状态】工具执行完毕,将上一条消息的 loading 关闭(消除闪烁光标)
|
||||
updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' });
|
||||
|
||||
// 插入过渡气泡
|
||||
const chainConnectingMsg: AIChatMessage = {
|
||||
id: genId(), role: 'assistant', phase: 'connecting',
|
||||
content: '汇总探针执行结果中',
|
||||
timestamp: Date.now(), loading: true,
|
||||
jvmPlanContext: inheritedJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
|
||||
};
|
||||
useStore.getState().addAIChatMessage(sid, chainConnectingMsg);
|
||||
|
||||
// 模拟人类视角的平滑多段过渡
|
||||
const safeUpdateTransition = (text: string) => {
|
||||
const currentMsg = useStore.getState().aiChatHistory[sid]?.find(m => m.id === chainConnectingMsg.id);
|
||||
// 只有当消息仍然处于连接过渡态时才允许修改文本;如果模型已经开始吐出思考、正文、工具或结束,直接退出
|
||||
if (currentMsg && currentMsg.phase === 'connecting' && currentMsg.loading) {
|
||||
updateAIChatMessage(sid, chainConnectingMsg.id, { content: text });
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => safeUpdateTransition('向模型回传运行时数据'), 200);
|
||||
setTimeout(() => safeUpdateTransition('模型大脑深度推理中'), 500);
|
||||
setTimeout(() => safeUpdateTransition('等待下发操作指令'), 1200);
|
||||
setTimeout(() => safeUpdateTransition('正在深度思考链路与逻辑'), 3000);
|
||||
|
||||
setSending(true);
|
||||
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
|
||||
// 过滤掉 connecting 占位消息,不发给模型
|
||||
const messagesPayload = currentHistory.filter(m => m.phase !== 'connecting').map(toAIRequestMessage);
|
||||
const sysMessages = await buildSystemContextMessages(
|
||||
inheritedJVMPlanContext,
|
||||
inheritedJVMDiagnosticPlanContext,
|
||||
);
|
||||
|
||||
let finalMessagesPayload = messagesPayload;
|
||||
// 在这里加入长度检查和自动摘要(带上动态限额)
|
||||
const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model);
|
||||
const summary = await compressContextIfNeeded(sid, messagesPayload, dynamicMaxLimit);
|
||||
if (summary) {
|
||||
const compressedMsg: AIChatMessage = {
|
||||
id: genId(), role: 'assistant', content: `【自动记忆重塑】已将超长历史探针数据和对话压缩为摘要:\n\n${summary}`, timestamp: Date.now() - 1000
|
||||
};
|
||||
const continueMsg: AIChatMessage = {
|
||||
id: genId(), role: 'user', content: '请根据上述最新状态与探索结果,继续完成你先前未竟的分析或执行下一步。', timestamp: Date.now() - 500
|
||||
};
|
||||
useStore.getState().replaceAIChatHistory(sid, [compressedMsg, continueMsg, chainConnectingMsg]);
|
||||
finalMessagesPayload = [
|
||||
{ role: 'assistant', content: compressedMsg.content },
|
||||
{ role: 'user', content: continueMsg.content }
|
||||
];
|
||||
}
|
||||
|
||||
const allMessages = [...sysMessages, ...finalMessagesPayload];
|
||||
|
||||
// 【软收敛】超过 10 轮工具调用后,不再传递 tools 参数,从物理层面强制模型只能用文本回答
|
||||
const SOFT_LIMIT_ROUNDS = 10;
|
||||
const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : availableTools;
|
||||
|
||||
await dispatchAIChatPayload({
|
||||
sid,
|
||||
messages: allMessages,
|
||||
tools: chainTools,
|
||||
addAIChatMessage: (sessionId, message) => useStore.getState().addAIChatMessage(sessionId, message),
|
||||
setSending,
|
||||
nextMessageId: genId,
|
||||
jvmPlanContext: inheritedJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to chain tool call', e);
|
||||
setSending(false);
|
||||
}
|
||||
}, [availableTools, buildSystemContextMessages, dynamicModels, mcpTools, sid, skills]);
|
||||
|
||||
useAIChatStreamSubscription({
|
||||
sid,
|
||||
sending,
|
||||
@@ -512,8 +365,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}
|
||||
setComposerNotice(null);
|
||||
|
||||
toolCallRoundRef.current = 0; // 重置工具调用轮次计数
|
||||
totalToolRoundRef.current = 0; // 重置总轮次计数
|
||||
resetToolCallState();
|
||||
nudgeCountRef.current = 0; // 重置催促计数
|
||||
const currentJVMPlanContext = getCurrentJVMPlanContext();
|
||||
const currentJVMDiagnosticPlanContext = getCurrentJVMDiagnosticPlanContext();
|
||||
|
||||
@@ -445,9 +445,10 @@ describe('aiLocalToolExecutor', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('"supportsWholeCommandAutoSplit":true');
|
||||
expect(result.content).toContain('"fullCommandPasteExample":"OPENAI_API_KEY=... uvx mcp-server-fetch --stdio"');
|
||||
expect(result.content).toContain('"fullCommandPasteExample":"$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio"');
|
||||
expect(result.content).toContain('"title":"启动命令"');
|
||||
expect(result.content).toContain('"example":"node / uvx / python"');
|
||||
expect(result.content).toContain('PowerShell $env:KEY=VALUE;');
|
||||
expect(result.content).toContain('"title":"uvx 工具"');
|
||||
expect(result.content).toContain('"exampleLaunchPreview":"uvx some-mcp-server"');
|
||||
});
|
||||
|
||||
194
frontend/src/components/ai/useAIChatLocalTools.test.tsx
Normal file
194
frontend/src/components/ai/useAIChatLocalTools.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { useStore } from '../../store';
|
||||
import type { AIToolCall } from '../../types';
|
||||
import { useAIChatLocalTools } from './useAIChatLocalTools';
|
||||
|
||||
const dispatchAIChatPayloadMock = vi.hoisted(() => vi.fn(async (_options: any) => 'stream'));
|
||||
const executeLocalAIToolCallMock = vi.hoisted(() => vi.fn(async ({ toolCall }: { toolCall: AIToolCall }) => ({
|
||||
content: `result:${toolCall.function.name}`,
|
||||
success: true,
|
||||
toolName: toolCall.function.name,
|
||||
})));
|
||||
|
||||
vi.mock('./aiChatPayloadDispatch', () => ({
|
||||
dispatchAIChatPayload: dispatchAIChatPayloadMock,
|
||||
}));
|
||||
|
||||
vi.mock('./aiLocalToolExecutor', () => ({
|
||||
executeLocalAIToolCall: executeLocalAIToolCallMock,
|
||||
buildToolResultMessage: ({ id, timestamp, toolCall, execution }: any) => ({
|
||||
id,
|
||||
role: 'tool',
|
||||
content: execution.content,
|
||||
timestamp,
|
||||
tool_call_id: toolCall.id,
|
||||
tool_name: execution.toolName,
|
||||
success: execution.success,
|
||||
}),
|
||||
}));
|
||||
|
||||
const SESSION_ID = 'session-local-tools';
|
||||
|
||||
const buildToolCall = (name: string): AIToolCall => ({
|
||||
id: `call-${name}`,
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: '{}',
|
||||
},
|
||||
});
|
||||
|
||||
const updateMessage = (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
patch: Parameters<ReturnType<typeof useStore.getState>['updateAIChatMessage']>[2],
|
||||
) => useStore.getState().updateAIChatMessage(sessionId, messageId, patch);
|
||||
|
||||
let latestHook: ReturnType<typeof useAIChatLocalTools> | undefined;
|
||||
|
||||
const LocalToolsHarness = () => {
|
||||
const [sending, setSending] = useState(false);
|
||||
const pendingJVMPlanContextRef = useRef<any>(undefined);
|
||||
const pendingJVMDiagnosticPlanContextRef = useRef<any>(undefined);
|
||||
|
||||
latestHook = useAIChatLocalTools({
|
||||
sid: SESSION_ID,
|
||||
activeProviderModel: 'gpt-5',
|
||||
availableTools: [{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'inspect_active_tab',
|
||||
description: 'inspect tab',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
}],
|
||||
buildSystemContextMessages: async () => [{ role: 'system', content: 'system-context' }],
|
||||
dynamicModels: ['gpt-5'],
|
||||
mcpTools: [],
|
||||
nextMessageId: () => `generated-${Math.random().toString(36).slice(2, 6)}`,
|
||||
pendingJVMPlanContextRef,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
setSending,
|
||||
skills: [],
|
||||
updateAIChatMessage: updateMessage,
|
||||
userPromptSettings: {
|
||||
global: '',
|
||||
database: '',
|
||||
jvm: '',
|
||||
jvmDiagnostic: '',
|
||||
},
|
||||
});
|
||||
|
||||
return <span data-sending={sending} />;
|
||||
};
|
||||
|
||||
describe('useAIChatLocalTools', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
dispatchAIChatPayloadMock.mockClear();
|
||||
executeLocalAIToolCallMock.mockClear();
|
||||
latestHook = undefined;
|
||||
useStore.setState({
|
||||
activeContext: { connectionId: 'conn-1', dbName: 'crm' },
|
||||
aiChatHistory: {
|
||||
[SESSION_ID]: [
|
||||
{ id: 'user-1', role: 'user', content: '查一下当前页签', timestamp: 1 },
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
loading: true,
|
||||
phase: 'tool_calling',
|
||||
tool_calls: [buildToolCall('inspect_active_tab')],
|
||||
},
|
||||
],
|
||||
},
|
||||
aiChatSessions: [{ id: SESSION_ID, title: '查一下当前页签', updatedAt: 1 }],
|
||||
aiActiveSessionId: SESSION_ID,
|
||||
connections: [{
|
||||
id: 'conn-1',
|
||||
name: '主库',
|
||||
config: {
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
},
|
||||
}],
|
||||
tabs: [{
|
||||
id: 'tab-1',
|
||||
title: '订单查询',
|
||||
type: 'query',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'crm',
|
||||
query: 'select * from orders',
|
||||
}],
|
||||
activeTabId: 'tab-1',
|
||||
aiContexts: {},
|
||||
sqlLogs: [],
|
||||
savedQueries: [],
|
||||
sqlSnippets: [],
|
||||
externalSQLDirectories: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
useStore.setState({
|
||||
activeContext: null,
|
||||
aiChatHistory: {},
|
||||
aiChatSessions: [],
|
||||
aiActiveSessionId: null,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
aiContexts: {},
|
||||
sqlLogs: [],
|
||||
savedQueries: [],
|
||||
sqlSnippets: [],
|
||||
externalSQLDirectories: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('writes tool results, closes the tool-calling message, and excludes connecting placeholders from the chained request', async () => {
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
await act(async () => {
|
||||
renderer = create(<LocalToolsHarness />);
|
||||
});
|
||||
|
||||
expect(latestHook).toBeDefined();
|
||||
const run = latestHook!.executeLocalTools([buildToolCall('inspect_active_tab')], 'assistant-1');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
await run;
|
||||
});
|
||||
|
||||
const messages = useStore.getState().aiChatHistory[SESSION_ID] || [];
|
||||
const assistant = messages.find((message) => message.id === 'assistant-1');
|
||||
const toolResult = messages.find((message) => message.role === 'tool');
|
||||
const connecting = messages.find((message) => message.phase === 'connecting');
|
||||
|
||||
expect(executeLocalAIToolCallMock).toHaveBeenCalledTimes(1);
|
||||
expect(assistant).toMatchObject({ loading: false, phase: 'idle' });
|
||||
expect(toolResult).toMatchObject({
|
||||
content: 'result:inspect_active_tab',
|
||||
success: true,
|
||||
tool_name: 'inspect_active_tab',
|
||||
});
|
||||
expect(connecting).toMatchObject({ content: '汇总探针执行结果中', loading: true });
|
||||
|
||||
expect(dispatchAIChatPayloadMock).toHaveBeenCalledTimes(1);
|
||||
const dispatchArgs = dispatchAIChatPayloadMock.mock.calls[0][0] as any;
|
||||
expect(dispatchArgs.messages[0]).toEqual({ role: 'system', content: 'system-context' });
|
||||
expect(JSON.stringify(dispatchArgs.messages)).toContain('result:inspect_active_tab');
|
||||
expect(JSON.stringify(dispatchArgs.messages)).not.toContain('汇总探针执行结果中');
|
||||
expect(dispatchArgs.tools).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
250
frontend/src/components/ai/useAIChatLocalTools.ts
Normal file
250
frontend/src/components/ai/useAIChatLocalTools.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import { useStore } from '../../store';
|
||||
import type {
|
||||
AIChatMessage,
|
||||
AIMCPToolDescriptor,
|
||||
AISkillConfig,
|
||||
AIToolCall,
|
||||
AIUserPromptSettings,
|
||||
JVMAIPlanContext,
|
||||
JVMDiagnosticPlanContext,
|
||||
} from '../../types';
|
||||
import { compressContextIfNeeded, getDynamicMaxContextChars } from '../../utils/aiChatRuntime';
|
||||
import { toAIRequestMessage } from '../../utils/aiMessagePayload';
|
||||
import type { AIChatToolDefinition } from '../../utils/aiToolRegistry';
|
||||
import { dispatchAIChatPayload } from './aiChatPayloadDispatch';
|
||||
import {
|
||||
buildToolResultMessage,
|
||||
executeLocalAIToolCall,
|
||||
type AIToolContextEntry,
|
||||
} from './aiLocalToolExecutor';
|
||||
|
||||
interface UseAIChatLocalToolsOptions {
|
||||
sid: string;
|
||||
activeProviderModel?: string;
|
||||
availableTools: AIChatToolDefinition[];
|
||||
buildSystemContextMessages: (
|
||||
overrideJVMPlanContext?: JVMAIPlanContext,
|
||||
overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext,
|
||||
) => any[] | Promise<any[]>;
|
||||
dynamicModels: string[];
|
||||
mcpTools: AIMCPToolDescriptor[];
|
||||
nextMessageId: () => string;
|
||||
pendingJVMPlanContextRef: MutableRefObject<JVMAIPlanContext | undefined>;
|
||||
pendingJVMDiagnosticPlanContextRef: MutableRefObject<JVMDiagnosticPlanContext | undefined>;
|
||||
setSending: (sending: boolean) => void;
|
||||
skills: AISkillConfig[];
|
||||
updateAIChatMessage: (
|
||||
sid: string,
|
||||
messageId: string,
|
||||
patch: Partial<AIChatMessage>,
|
||||
) => void;
|
||||
userPromptSettings: AIUserPromptSettings;
|
||||
}
|
||||
|
||||
const MAX_TOOL_CALL_ROUNDS = 15;
|
||||
const SOFT_LIMIT_ROUNDS = 10;
|
||||
|
||||
export const useAIChatLocalTools = ({
|
||||
sid,
|
||||
activeProviderModel,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
dynamicModels,
|
||||
mcpTools,
|
||||
nextMessageId,
|
||||
pendingJVMPlanContextRef,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
setSending,
|
||||
skills,
|
||||
updateAIChatMessage,
|
||||
userPromptSettings,
|
||||
}: UseAIChatLocalToolsOptions) => {
|
||||
const toolCallRoundRef = useRef(0);
|
||||
const totalToolRoundRef = useRef(0);
|
||||
const toolContextMapRef = useRef<Map<string, AIToolContextEntry>>(new Map());
|
||||
|
||||
const resetToolCallState = useCallback(() => {
|
||||
toolCallRoundRef.current = 0;
|
||||
totalToolRoundRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const executeLocalTools = useCallback(async (toolCalls: AIToolCall[], currentAsstMsgId: string) => {
|
||||
const store = useStore.getState();
|
||||
const currentAsstMsg = (store.aiChatHistory[sid] || []).find((message) => message.id === currentAsstMsgId);
|
||||
const inheritedJVMPlanContext = currentAsstMsg?.jvmPlanContext || pendingJVMPlanContextRef.current;
|
||||
const inheritedJVMDiagnosticPlanContext =
|
||||
currentAsstMsg?.jvmDiagnosticPlanContext || pendingJVMDiagnosticPlanContextRef.current;
|
||||
pendingJVMPlanContextRef.current = inheritedJVMPlanContext;
|
||||
pendingJVMDiagnosticPlanContextRef.current = inheritedJVMDiagnosticPlanContext;
|
||||
|
||||
totalToolRoundRef.current += 1;
|
||||
if (totalToolRoundRef.current > MAX_TOOL_CALL_ROUNDS) {
|
||||
updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' });
|
||||
useStore.getState().addAIChatMessage(sid, {
|
||||
id: nextMessageId(),
|
||||
role: 'assistant',
|
||||
content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`,
|
||||
timestamp: Date.now(),
|
||||
jvmPlanContext: inheritedJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
|
||||
});
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: AIChatMessage[] = [];
|
||||
const currentConnections = useStore.getState().connections;
|
||||
for (const toolCall of toolCalls) {
|
||||
const currentState = useStore.getState();
|
||||
const execution = await executeLocalAIToolCall({
|
||||
toolCall,
|
||||
connections: currentConnections,
|
||||
activeContext: currentState.activeContext,
|
||||
aiContexts: currentState.aiContexts,
|
||||
aiChatHistory: currentState.aiChatHistory,
|
||||
aiChatSessions: currentState.aiChatSessions,
|
||||
activeSessionId: sid,
|
||||
tabs: currentState.tabs,
|
||||
activeTabId: currentState.activeTabId,
|
||||
mcpTools,
|
||||
toolContextMap: toolContextMapRef.current,
|
||||
sqlLogs: currentState.sqlLogs,
|
||||
savedQueries: currentState.savedQueries,
|
||||
sqlSnippets: currentState.sqlSnippets,
|
||||
externalSQLDirectories: currentState.externalSQLDirectories,
|
||||
skills,
|
||||
userPromptSettings,
|
||||
dynamicModels,
|
||||
});
|
||||
const toolResultMsg: AIChatMessage = buildToolResultMessage({
|
||||
id: nextMessageId(),
|
||||
timestamp: Date.now(),
|
||||
toolCall,
|
||||
execution,
|
||||
});
|
||||
results.push(toolResultMsg);
|
||||
useStore.getState().addAIChatMessage(sid, toolResultMsg);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
const anySuccess = results.some((message) => message.success === true);
|
||||
if (anySuccess) {
|
||||
toolCallRoundRef.current = 0;
|
||||
} else {
|
||||
toolCallRoundRef.current += 1;
|
||||
if (toolCallRoundRef.current >= 3) {
|
||||
useStore.getState().addAIChatMessage(sid, {
|
||||
id: nextMessageId(),
|
||||
role: 'assistant',
|
||||
content: '⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。',
|
||||
timestamp: Date.now(),
|
||||
jvmPlanContext: inheritedJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
|
||||
});
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' });
|
||||
|
||||
const chainConnectingMsg: AIChatMessage = {
|
||||
id: nextMessageId(),
|
||||
role: 'assistant',
|
||||
phase: 'connecting',
|
||||
content: '汇总探针执行结果中',
|
||||
timestamp: Date.now(),
|
||||
loading: true,
|
||||
jvmPlanContext: inheritedJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
|
||||
};
|
||||
useStore.getState().addAIChatMessage(sid, chainConnectingMsg);
|
||||
|
||||
const safeUpdateTransition = (text: string) => {
|
||||
const currentMsg = useStore.getState().aiChatHistory[sid]?.find((message) => message.id === chainConnectingMsg.id);
|
||||
if (currentMsg && currentMsg.phase === 'connecting' && currentMsg.loading) {
|
||||
updateAIChatMessage(sid, chainConnectingMsg.id, { content: text });
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => safeUpdateTransition('向模型回传运行时数据'), 200);
|
||||
setTimeout(() => safeUpdateTransition('模型大脑深度推理中'), 500);
|
||||
setTimeout(() => safeUpdateTransition('等待下发操作指令'), 1200);
|
||||
setTimeout(() => safeUpdateTransition('正在深度思考链路与逻辑'), 3000);
|
||||
|
||||
setSending(true);
|
||||
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
|
||||
const messagesPayload = currentHistory
|
||||
.filter((message) => message.phase !== 'connecting')
|
||||
.map(toAIRequestMessage);
|
||||
const sysMessages = await buildSystemContextMessages(
|
||||
inheritedJVMPlanContext,
|
||||
inheritedJVMDiagnosticPlanContext,
|
||||
);
|
||||
|
||||
let finalMessagesPayload = messagesPayload;
|
||||
const dynamicMaxLimit = getDynamicMaxContextChars(activeProviderModel);
|
||||
const summary = await compressContextIfNeeded(sid, messagesPayload, dynamicMaxLimit);
|
||||
if (summary) {
|
||||
const compressedMsg: AIChatMessage = {
|
||||
id: nextMessageId(),
|
||||
role: 'assistant',
|
||||
content: `【自动记忆重塑】已将超长历史探针数据和对话压缩为摘要:\n\n${summary}`,
|
||||
timestamp: Date.now() - 1000,
|
||||
};
|
||||
const continueMsg: AIChatMessage = {
|
||||
id: nextMessageId(),
|
||||
role: 'user',
|
||||
content: '请根据上述最新状态与探索结果,继续完成你先前未竟的分析或执行下一步。',
|
||||
timestamp: Date.now() - 500,
|
||||
};
|
||||
useStore.getState().replaceAIChatHistory(sid, [compressedMsg, continueMsg, chainConnectingMsg]);
|
||||
finalMessagesPayload = [
|
||||
{ role: 'assistant', content: compressedMsg.content },
|
||||
{ role: 'user', content: continueMsg.content },
|
||||
];
|
||||
}
|
||||
|
||||
const allMessages = [...sysMessages, ...finalMessagesPayload];
|
||||
const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : availableTools;
|
||||
|
||||
await dispatchAIChatPayload({
|
||||
sid,
|
||||
messages: allMessages,
|
||||
tools: chainTools,
|
||||
addAIChatMessage: (sessionId, message) => useStore.getState().addAIChatMessage(sessionId, message),
|
||||
setSending,
|
||||
nextMessageId,
|
||||
jvmPlanContext: inheritedJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to chain tool call', error);
|
||||
setSending(false);
|
||||
}
|
||||
}, [
|
||||
activeProviderModel,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
dynamicModels,
|
||||
mcpTools,
|
||||
nextMessageId,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
pendingJVMPlanContextRef,
|
||||
setSending,
|
||||
sid,
|
||||
skills,
|
||||
updateAIChatMessage,
|
||||
userPromptSettings,
|
||||
]);
|
||||
|
||||
return {
|
||||
executeLocalTools,
|
||||
resetToolCallState,
|
||||
toolContextMapRef,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user