♻️ refactor(ai-chat): 拆分本地工具调用链

- 抽出 useAIChatLocalTools 承载工具执行、熔断和回灌模型逻辑

- 补齐重试消息的工具上下文依赖,避免配置变更后使用旧闭包

- 增加 hook 行为测试并同步 MCP 指南断言
This commit is contained in:
Syngnat
2026-06-09 21:18:39 +08:00
parent 67e0cc752b
commit f0afff68c4
5 changed files with 505 additions and 200 deletions

View File

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

View File

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

View File

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

View 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();
});
});
});

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