mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-05 10:01:30 +08:00
✨ feat(ai): 增强 MCP 远程接入与上下文诊断
This commit is contained in:
@@ -88,6 +88,9 @@ describe('AIBuiltinToolsCatalog', () => {
|
||||
expect(markup).toContain('inspect_ai_last_render_error');
|
||||
expect(markup).toContain('诊断 AI 消息流');
|
||||
expect(markup).toContain('inspect_ai_message_flow');
|
||||
expect(markup).toContain('诊断 AI 上下文体量');
|
||||
expect(markup).toContain('inspect_ai_context_budget');
|
||||
expect(markup).toContain('messageLimit');
|
||||
expect(markup).toContain('复用历史 SQL');
|
||||
expect(markup).toContain('inspect_saved_queries');
|
||||
expect(markup).toContain('回看 AI 历史对话');
|
||||
|
||||
@@ -194,9 +194,11 @@ describe('AIMCPClientInstallPanel', () => {
|
||||
expect(markup).toContain('云端 Agent 只通过 MCP 工具读取连接摘要、库表和 DDL');
|
||||
expect(markup).toContain('OpenClaw 远程 MCP 快速配置');
|
||||
expect(markup).toContain('配置到云端 Agent');
|
||||
expect(markup).toContain('无 GUI / CLI 生成配置');
|
||||
expect(markup).toContain('"type": "streamable-http"');
|
||||
expect(markup).toContain('"url": "https://<你的域名或隧道地址>/mcp"');
|
||||
expect(markup).toContain('"Authorization": "Bearer <随机token>"');
|
||||
expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token>');
|
||||
expect(markup).toContain('Windows 启动 GoNavi MCP HTTP');
|
||||
expect(markup).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>');
|
||||
expect(markup).toContain('独立二进制:gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>');
|
||||
|
||||
@@ -310,7 +310,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
|
||||
{remoteQuickStart.displayName} 远程 MCP 快速配置
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
下面两段分别给云端 Agent 和 Windows GoNavi 使用。云端只保存 MCP URL 和 Bearer Token,不保存数据库账号密码。
|
||||
下面分别给云端 Agent、无 GUI/CLI 场景和 Windows GoNavi 使用。云端只保存 MCP URL 和 Bearer Token,不保存数据库账号密码。
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}>
|
||||
<div
|
||||
@@ -338,6 +338,34 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
|
||||
{remoteQuickStart.configJson}
|
||||
</code>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: darkMode ? 'rgba(15,23,42,0.55)' : 'rgba(255,255,255,0.78)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 12, color: overlayTheme.titleText }}>
|
||||
无 GUI / CLI 生成配置
|
||||
</div>
|
||||
<code
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: 8,
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
fontSize: 11,
|
||||
color: overlayTheme.titleText,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{remoteQuickStart.configCommand}
|
||||
</code>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
|
||||
用于生成可粘贴到 {remoteQuickStart.displayName} 的远程 MCP 配置,不会读取或输出数据库密码。
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
|
||||
91
frontend/src/components/ai/aiContextBudgetInsights.test.ts
Normal file
91
frontend/src/components/ai/aiContextBudgetInsights.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAIContextBudgetSnapshot } from './aiContextBudgetInsights';
|
||||
|
||||
describe('aiContextBudgetInsights', () => {
|
||||
it('summarizes context budget sources and warns when schema/tool results are oversized', () => {
|
||||
const longToolResult = 'x'.repeat(22000);
|
||||
const longDDL = `CREATE TABLE big_table (${Array.from({ length: 300 }, (_, index) => `c${index} varchar(255)`).join(',')})`;
|
||||
const snapshot = buildAIContextBudgetSnapshot({
|
||||
activeSessionId: 'session-1',
|
||||
aiChatSessions: [{ id: 'session-1', title: '上下文体量排查', updatedAt: 1 }],
|
||||
aiChatHistory: {
|
||||
'session-1': [
|
||||
{ id: 'msg-1', role: 'user', content: 'AI 变慢了', timestamp: 1 },
|
||||
{
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
content: '先看日志',
|
||||
timestamp: 2,
|
||||
tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'inspect_app_logs', arguments: '{}' } }],
|
||||
},
|
||||
{ id: 'msg-3', role: 'tool', content: longToolResult, timestamp: 3, tool_call_id: 'tool-1' },
|
||||
],
|
||||
},
|
||||
aiContexts: {
|
||||
'conn-1:crm': [
|
||||
{ dbName: 'crm', tableName: 'big_table', ddl: longDDL },
|
||||
],
|
||||
},
|
||||
mcpTools: [{
|
||||
alias: 'remote_probe',
|
||||
serverId: 'mcp-1',
|
||||
serverName: 'demo',
|
||||
originalName: 'probe',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: Object.fromEntries(Array.from({ length: 20 }, (_, index) => [`field${index}`, { type: 'string' }])),
|
||||
},
|
||||
}],
|
||||
skills: [{
|
||||
id: 'skill-1',
|
||||
name: '结构审查',
|
||||
systemPrompt: '先看结构',
|
||||
enabled: true,
|
||||
scopes: ['database'],
|
||||
}],
|
||||
userPromptSettings: {
|
||||
global: '全局提示',
|
||||
database: '数据库提示',
|
||||
jvm: '',
|
||||
jvmDiagnostic: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.foundSession).toBe(true);
|
||||
expect(snapshot.title).toBe('上下文体量排查');
|
||||
expect(snapshot.messageWindow.toolResultChars).toBe(22000);
|
||||
expect(snapshot.messageWindow.unresolvedToolCallCount).toBe(0);
|
||||
expect(snapshot.schemaContext.tableCount).toBe(1);
|
||||
expect(snapshot.schemaContext.largestTables[0]).toMatchObject({ tableName: 'big_table' });
|
||||
expect(snapshot.toolCatalog.mcpToolCount).toBe(1);
|
||||
expect(snapshot.promptsAndSkills.enabledSkillNames).toContain('结构审查');
|
||||
expect(snapshot.warnings).toContain('最近工具结果较长,可能导致后续回答被日志或大结果集稀释');
|
||||
expect(snapshot.nextActions).toContain('降低 inspect_app_logs / inspect_recent_sql_logs / includeDDL / includeLogLines 的返回量');
|
||||
});
|
||||
|
||||
it('reports missing sessions and unresolved tool calls', () => {
|
||||
const snapshot = buildAIContextBudgetSnapshot({
|
||||
activeSessionId: 'missing',
|
||||
aiChatSessions: [],
|
||||
aiChatHistory: {
|
||||
missing: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: '调用工具',
|
||||
timestamp: 1,
|
||||
tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'inspect_ai_runtime', arguments: '{}' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
expect(snapshot.foundSession).toBe(false);
|
||||
expect(snapshot.messageWindow.unresolvedToolCallCount).toBe(1);
|
||||
expect(snapshot.warnings).toContain('未找到目标 AI 会话,消息体量统计只覆盖空窗口');
|
||||
expect(snapshot.warnings).toContain('最近消息窗口内有 1 个未闭环工具调用');
|
||||
expect(snapshot.nextActions).toContain('先调用 inspect_ai_message_flow 确认工具调用是否缺少 tool 结果消息');
|
||||
});
|
||||
});
|
||||
246
frontend/src/components/ai/aiContextBudgetInsights.ts
Normal file
246
frontend/src/components/ai/aiContextBudgetInsights.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type {
|
||||
AIChatMessage,
|
||||
AIContextItem,
|
||||
AIMCPToolDescriptor,
|
||||
AISkillConfig,
|
||||
AIUserPromptSettings,
|
||||
} from '../../types';
|
||||
|
||||
type ContextRiskLevel = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
interface BuildAIContextBudgetSnapshotOptions {
|
||||
aiContexts?: Record<string, AIContextItem[]>;
|
||||
aiChatHistory?: Record<string, AIChatMessage[]>;
|
||||
aiChatSessions?: Array<{ id: string; title: string; updatedAt: number }>;
|
||||
activeSessionId?: string | null;
|
||||
sessionId?: unknown;
|
||||
messageLimit?: unknown;
|
||||
includeDetails?: unknown;
|
||||
mcpTools?: AIMCPToolDescriptor[];
|
||||
skills?: AISkillConfig[];
|
||||
userPromptSettings?: AIUserPromptSettings;
|
||||
}
|
||||
|
||||
const DEFAULT_MESSAGE_LIMIT = 40;
|
||||
const MAX_MESSAGE_LIMIT = 120;
|
||||
const MESSAGE_PREVIEW_LIMIT = 160;
|
||||
|
||||
const clampNumber = (value: unknown, fallback: number, min: number, max: number): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(max, Math.max(min, Math.round(parsed)));
|
||||
};
|
||||
|
||||
const charCount = (value: unknown): number => String(value || '').length;
|
||||
|
||||
const estimateTokens = (chars: number): number => Math.ceil(Math.max(0, chars) / 3);
|
||||
|
||||
const previewText = (value: unknown, limit = MESSAGE_PREVIEW_LIMIT): string => {
|
||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= limit) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, limit)}...`;
|
||||
};
|
||||
|
||||
const classifyRisk = (estimatedInputChars: number): ContextRiskLevel => {
|
||||
if (estimatedInputChars >= 120000) {
|
||||
return 'critical';
|
||||
}
|
||||
if (estimatedInputChars >= 70000) {
|
||||
return 'high';
|
||||
}
|
||||
if (estimatedInputChars >= 30000) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
};
|
||||
|
||||
const appendUnique = (items: string[], item: string) => {
|
||||
if (!items.includes(item)) {
|
||||
items.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
const getMessagePayloadChars = (message: AIChatMessage): number =>
|
||||
charCount(message.content)
|
||||
+ charCount(message.thinking)
|
||||
+ charCount(message.reasoning_content)
|
||||
+ (message.tool_calls ? charCount(JSON.stringify(message.tool_calls)) : 0);
|
||||
|
||||
export const buildAIContextBudgetSnapshot = ({
|
||||
aiContexts = {},
|
||||
aiChatHistory = {},
|
||||
aiChatSessions = [],
|
||||
activeSessionId = null,
|
||||
sessionId,
|
||||
messageLimit,
|
||||
includeDetails,
|
||||
mcpTools = [],
|
||||
skills = [],
|
||||
userPromptSettings,
|
||||
}: BuildAIContextBudgetSnapshotOptions) => {
|
||||
const requestedSessionId = String(sessionId || activeSessionId || '').trim();
|
||||
const allMessages = requestedSessionId ? (aiChatHistory[requestedSessionId] || []) : [];
|
||||
const effectiveMessageLimit = clampNumber(messageLimit, DEFAULT_MESSAGE_LIMIT, 1, MAX_MESSAGE_LIMIT);
|
||||
const inspectedMessages = allMessages.slice(-effectiveMessageLimit);
|
||||
const shouldIncludeDetails = includeDetails !== false;
|
||||
const session = aiChatSessions.find((item) => item.id === requestedSessionId);
|
||||
|
||||
const messageRoleCounts = inspectedMessages.reduce<Record<string, number>>((acc, message) => {
|
||||
acc[message.role] = (acc[message.role] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const messagePayloadChars = inspectedMessages.reduce((sum, message) => sum + getMessagePayloadChars(message), 0);
|
||||
const toolResultChars = inspectedMessages
|
||||
.filter((message) => message.role === 'tool')
|
||||
.reduce((sum, message) => sum + charCount(message.content), 0);
|
||||
const thinkingChars = inspectedMessages.reduce(
|
||||
(sum, message) => sum + charCount(message.thinking) + charCount(message.reasoning_content),
|
||||
0,
|
||||
);
|
||||
const unresolvedToolCallIds = new Set<string>();
|
||||
inspectedMessages.forEach((message) => {
|
||||
(message.tool_calls || []).forEach((toolCall) => unresolvedToolCallIds.add(toolCall.id));
|
||||
if (message.role === 'tool' && message.tool_call_id) {
|
||||
unresolvedToolCallIds.delete(message.tool_call_id);
|
||||
}
|
||||
});
|
||||
|
||||
const contextEntries = Object.entries(aiContexts).flatMap(([contextKey, items]) => (
|
||||
(items || []).map((item) => ({ contextKey, item }))
|
||||
));
|
||||
const ddlChars = contextEntries.reduce((sum, entry) => sum + charCount(entry.item.ddl), 0);
|
||||
const largestTables = contextEntries
|
||||
.map((entry) => ({
|
||||
contextKey: entry.contextKey,
|
||||
dbName: entry.item.dbName,
|
||||
tableName: entry.item.tableName,
|
||||
ddlChars: charCount(entry.item.ddl),
|
||||
}))
|
||||
.sort((left, right) => right.ddlChars - left.ddlChars)
|
||||
.slice(0, shouldIncludeDetails ? 8 : 3);
|
||||
|
||||
const mcpSchemaChars = mcpTools.reduce((sum, tool) => (
|
||||
sum
|
||||
+ charCount(tool.alias)
|
||||
+ charCount(tool.description)
|
||||
+ charCount(tool.inputSchema ? JSON.stringify(tool.inputSchema) : '')
|
||||
), 0);
|
||||
const largestMCPTools = [...mcpTools]
|
||||
.map((tool) => ({
|
||||
alias: tool.alias,
|
||||
serverName: tool.serverName,
|
||||
schemaChars: charCount(tool.inputSchema ? JSON.stringify(tool.inputSchema) : ''),
|
||||
}))
|
||||
.sort((left, right) => right.schemaChars - left.schemaChars)
|
||||
.slice(0, shouldIncludeDetails ? 8 : 3);
|
||||
|
||||
const enabledSkills = skills.filter((skill) => skill.enabled);
|
||||
const skillPromptChars = enabledSkills.reduce((sum, skill) => (
|
||||
sum + charCount(skill.name) + charCount(skill.description) + charCount(skill.systemPrompt)
|
||||
), 0);
|
||||
const userPromptChars = userPromptSettings
|
||||
? charCount(userPromptSettings.global)
|
||||
+ charCount(userPromptSettings.database)
|
||||
+ charCount(userPromptSettings.jvm)
|
||||
+ charCount(userPromptSettings.jvmDiagnostic)
|
||||
: 0;
|
||||
|
||||
const estimatedInputChars = messagePayloadChars + ddlChars + mcpSchemaChars + skillPromptChars + userPromptChars;
|
||||
const riskLevel = classifyRisk(estimatedInputChars);
|
||||
const warnings: string[] = [];
|
||||
const nextActions: string[] = [];
|
||||
|
||||
if (!requestedSessionId || !session) {
|
||||
appendUnique(warnings, '未找到目标 AI 会话,消息体量统计只覆盖空窗口');
|
||||
appendUnique(nextActions, '先打开或选中目标 AI 会话,再重新调用 inspect_ai_context_budget');
|
||||
}
|
||||
if (riskLevel === 'critical') {
|
||||
appendUnique(warnings, '当前 AI 输入上下文体量达到 critical,可能导致回复慢、截断或模型忽略关键约束');
|
||||
} else if (riskLevel === 'high') {
|
||||
appendUnique(warnings, '当前 AI 输入上下文体量偏高,复杂问题前建议先收窄上下文');
|
||||
}
|
||||
if (ddlChars >= 60000 || contextEntries.length >= 30) {
|
||||
appendUnique(warnings, '已挂载表结构较多或 DDL 较长,可能挤占用户问题和工具结果空间');
|
||||
appendUnique(nextActions, '只保留本轮相关表,必要时改用 inspect_table_bundle 按需读取目标表');
|
||||
}
|
||||
if (messagePayloadChars >= 40000) {
|
||||
appendUnique(warnings, '当前会话最近消息内容较长,可能影响后续回复稳定性');
|
||||
appendUnique(nextActions, '新开会话或先让 AI 总结当前结论,再继续下一轮复杂任务');
|
||||
}
|
||||
if (toolResultChars >= 20000) {
|
||||
appendUnique(warnings, '最近工具结果较长,可能导致后续回答被日志或大结果集稀释');
|
||||
appendUnique(nextActions, '降低 inspect_app_logs / inspect_recent_sql_logs / includeDDL / includeLogLines 的返回量');
|
||||
}
|
||||
if (mcpTools.length >= 40 || mcpSchemaChars >= 30000) {
|
||||
appendUnique(warnings, '当前暴露的 MCP 工具或 schema 较多,模型选择工具时可能更容易走偏');
|
||||
appendUnique(nextActions, '临时禁用无关 MCP 服务,或先调用 inspect_ai_tool_catalog 按关键词收窄工具路线');
|
||||
}
|
||||
if (enabledSkills.length >= 8 || skillPromptChars >= 16000) {
|
||||
appendUnique(warnings, '当前启用 Skills 较多或提示词较长,可能叠加冲突约束');
|
||||
appendUnique(nextActions, '仅保留本轮任务相关 Skills,完成后再恢复其它 Skills');
|
||||
}
|
||||
if (unresolvedToolCallIds.size > 0) {
|
||||
appendUnique(warnings, `最近消息窗口内有 ${unresolvedToolCallIds.size} 个未闭环工具调用`);
|
||||
appendUnique(nextActions, '先调用 inspect_ai_message_flow 确认工具调用是否缺少 tool 结果消息');
|
||||
}
|
||||
if (warnings.length === 0) {
|
||||
appendUnique(nextActions, '当前上下文体量可控,可继续按具体问题调用更窄的结构、日志或 SQL 风险探针');
|
||||
}
|
||||
|
||||
const largestMessages = inspectedMessages
|
||||
.map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
chars: getMessagePayloadChars(message),
|
||||
preview: shouldIncludeDetails ? previewText(message.content) : undefined,
|
||||
}))
|
||||
.sort((left, right) => right.chars - left.chars)
|
||||
.slice(0, shouldIncludeDetails ? 8 : 3);
|
||||
|
||||
return {
|
||||
requestedSessionId: requestedSessionId || null,
|
||||
activeSessionId,
|
||||
foundSession: Boolean(session),
|
||||
title: session?.title || '',
|
||||
riskLevel,
|
||||
estimatedInputChars,
|
||||
estimatedInputTokens: estimateTokens(estimatedInputChars),
|
||||
messageWindow: {
|
||||
totalMessages: allMessages.length,
|
||||
inspectedMessages: inspectedMessages.length,
|
||||
limit: effectiveMessageLimit,
|
||||
roleCounts: messageRoleCounts,
|
||||
payloadChars: messagePayloadChars,
|
||||
thinkingChars,
|
||||
toolResultChars,
|
||||
unresolvedToolCallCount: unresolvedToolCallIds.size,
|
||||
largestMessages,
|
||||
},
|
||||
schemaContext: {
|
||||
contextCount: Object.keys(aiContexts).length,
|
||||
tableCount: contextEntries.length,
|
||||
ddlChars,
|
||||
estimatedTokens: estimateTokens(ddlChars),
|
||||
largestTables,
|
||||
},
|
||||
toolCatalog: {
|
||||
mcpToolCount: mcpTools.length,
|
||||
mcpSchemaChars,
|
||||
estimatedTokens: estimateTokens(mcpSchemaChars),
|
||||
largestMCPTools,
|
||||
},
|
||||
promptsAndSkills: {
|
||||
userPromptChars,
|
||||
enabledSkillCount: enabledSkills.length,
|
||||
skillPromptChars,
|
||||
estimatedTokens: estimateTokens(userPromptChars + skillPromptChars),
|
||||
enabledSkillNames: enabledSkills.map((skill) => skill.name).slice(0, shouldIncludeDetails ? 20 : 8),
|
||||
},
|
||||
warnings,
|
||||
nextActions,
|
||||
};
|
||||
};
|
||||
@@ -139,6 +139,61 @@ describe('aiLocalToolExecutor local asset inspection tools', () => {
|
||||
expect(result.content).toContain('回复拆成多个气泡');
|
||||
});
|
||||
|
||||
it('returns ai context budget diagnostics for the active session', async () => {
|
||||
const result = await executeLocalAIToolCall({
|
||||
toolCall: buildToolCall('inspect_ai_context_budget', {
|
||||
messageLimit: 10,
|
||||
}),
|
||||
connections: [buildConnection()],
|
||||
mcpTools: [{
|
||||
alias: 'remote_probe',
|
||||
originalName: 'remote_probe',
|
||||
serverId: 'server-1',
|
||||
serverName: '远程工具',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: { type: 'string' },
|
||||
},
|
||||
},
|
||||
}],
|
||||
toolContextMap: new Map(),
|
||||
aiChatSessions: [
|
||||
{ id: 'session-1', title: '上下文预算排查', updatedAt: 200 },
|
||||
],
|
||||
aiChatHistory: {
|
||||
'session-1': [
|
||||
{ id: 'msg-1', role: 'user', content: 'AI 变慢是不是上下文太大', timestamp: 101 },
|
||||
{ id: 'msg-2', role: 'tool', content: 'x'.repeat(21000), timestamp: 102, tool_call_id: 'tool-1' },
|
||||
],
|
||||
},
|
||||
activeSessionId: 'session-1',
|
||||
aiContexts: {
|
||||
'conn-1:crm': [
|
||||
{ dbName: 'crm', tableName: 'orders', ddl: 'CREATE TABLE orders(id bigint, amount decimal(10,2));' },
|
||||
],
|
||||
},
|
||||
skills: [{
|
||||
id: 'skill-1',
|
||||
name: 'SQL 审查',
|
||||
systemPrompt: '先检查风险',
|
||||
enabled: true,
|
||||
scopes: ['database'],
|
||||
}],
|
||||
runtime: {
|
||||
getDatabases: vi.fn(),
|
||||
getTables: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('"title":"上下文预算排查"');
|
||||
expect(result.content).toContain('"toolResultChars":21000');
|
||||
expect(result.content).toContain('"tableName":"orders"');
|
||||
expect(result.content).toContain('"mcpToolCount":1');
|
||||
expect(result.content).toContain('最近工具结果较长');
|
||||
});
|
||||
|
||||
it('returns sql snippets so the model can inspect local query templates', async () => {
|
||||
const result = await executeLocalAIToolCall({
|
||||
toolCall: buildToolCall('inspect_sql_snippets', {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
buildAIChatSessionsSnapshot,
|
||||
buildAIMessageFlowSnapshot,
|
||||
} from './aiChatSessionInsights';
|
||||
import { buildAIContextBudgetSnapshot } from './aiContextBudgetInsights';
|
||||
import { buildConnectionCapabilitiesSnapshot } from './aiConnectionCapabilitiesInsights';
|
||||
import { buildCurrentConnectionSnapshot } from './aiConnectionInsights';
|
||||
import {
|
||||
@@ -280,6 +281,22 @@ export async function executeSnapshotInspectionToolCall(
|
||||
})),
|
||||
success: true,
|
||||
};
|
||||
case 'inspect_ai_context_budget':
|
||||
return {
|
||||
content: JSON.stringify(buildAIContextBudgetSnapshot({
|
||||
aiContexts,
|
||||
aiChatHistory,
|
||||
aiChatSessions,
|
||||
activeSessionId,
|
||||
sessionId: args.sessionId,
|
||||
messageLimit: args.messageLimit,
|
||||
includeDetails: args.includeDetails,
|
||||
mcpTools,
|
||||
skills,
|
||||
userPromptSettings,
|
||||
})),
|
||||
success: true,
|
||||
};
|
||||
case 'inspect_recent_sql_logs':
|
||||
return {
|
||||
content: JSON.stringify(buildRecentSqlLogsSnapshot({
|
||||
@@ -426,6 +443,7 @@ export async function executeSnapshotInspectionToolCall(
|
||||
inspect_recent_connection_failures: '汇总最近连接失败记录失败',
|
||||
inspect_ai_last_render_error: '读取最近一次 AI 渲染异常失败',
|
||||
inspect_ai_message_flow: '读取 AI 消息流诊断失败',
|
||||
inspect_ai_context_budget: '读取 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_app_health', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_ai_tool_catalog', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_mcp_draft', 'inspect_mcp_tool_schema', '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_sql_editor_transaction', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_ai_message_flow', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'],
|
||||
availableToolNames: ['inspect_workspace_tabs', 'inspect_app_health', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_ai_tool_catalog', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_mcp_draft', 'inspect_mcp_tool_schema', '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_sql_editor_transaction', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_ai_message_flow', 'inspect_ai_context_budget', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'],
|
||||
skills,
|
||||
userPromptSettings,
|
||||
});
|
||||
@@ -100,6 +100,7 @@ describe('buildAISystemContextMessages', () => {
|
||||
expect(joined).toContain('inspect_app_logs 读取真实应用日志尾部');
|
||||
expect(joined).toContain('inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录');
|
||||
expect(joined).toContain('inspect_ai_message_flow 读取当前会话的真实消息结构');
|
||||
expect(joined).toContain('inspect_ai_context_budget 读取消息、DDL、MCP schema、提示词和 Skills 的体量风险');
|
||||
expect(joined).toContain('inspect_saved_queries');
|
||||
expect(joined).toContain('inspect_ai_sessions');
|
||||
expect(joined).toContain('inspect_sql_snippets');
|
||||
|
||||
@@ -146,6 +146,12 @@ export const appendDatabaseInspectionGuidanceMessages = (
|
||||
'inspect_ai_message_flow',
|
||||
'如果用户提到“AI 回复被拆成多个气泡”“工具调用后没继续回答”“消息流状态不对”“同一轮回答没有追加到同一个气泡”,优先调用 inspect_ai_message_flow 读取当前会话的真实消息结构、连续 assistant 消息和未闭环工具调用,不要只凭界面现象猜测。',
|
||||
);
|
||||
appendGuidanceIfToolAvailable(
|
||||
messages,
|
||||
availableToolNames,
|
||||
'inspect_ai_context_budget',
|
||||
'如果用户提到“AI 变慢”“上下文太大”“表结构挂太多”“工具结果太长”“模型开始乱答”或复杂任务前需要判断是否该拆小上下文,优先调用 inspect_ai_context_budget 读取消息、DDL、MCP schema、提示词和 Skills 的体量风险,再决定收窄上下文或拆任务。',
|
||||
);
|
||||
appendGuidanceIfToolAvailable(
|
||||
messages,
|
||||
availableToolNames,
|
||||
|
||||
@@ -59,6 +59,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
|
||||
inspect_recent_connection_failures: '总结最近连接失败记录',
|
||||
inspect_ai_last_render_error: '读取最近一次 AI 渲染异常',
|
||||
inspect_ai_message_flow: '诊断当前 AI 消息流',
|
||||
inspect_ai_context_budget: '诊断 AI 上下文体量风险',
|
||||
inspect_saved_queries: '检索本地已保存查询',
|
||||
inspect_sql_snippets: '读取 SQL 片段模板',
|
||||
inspect_shortcuts: '读取当前快捷键配置',
|
||||
|
||||
@@ -701,6 +701,30 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inspect_ai_context_budget",
|
||||
icon: "📦",
|
||||
desc: "诊断 AI 上下文体量与稳定性风险",
|
||||
detail:
|
||||
"统计当前或指定 AI 会话的最近消息、工具结果、已挂载表结构、MCP 工具 schema、用户提示词和 Skills 体量,返回 low/medium/high/critical 风险、主要膨胀来源和收窄建议。适合用户反馈 AI 变慢、乱答、上下文太大、工具结果过长或表结构挂太多时先做预算体检。",
|
||||
params: "sessionId?(默认当前会话), messageLimit?(默认 40), includeDetails?(默认 true)",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "inspect_ai_context_budget",
|
||||
description:
|
||||
"读取当前 AI 上下文体量与稳定性风险快照,包括最近消息窗口、tool 结果长度、已挂载表结构 DDL、MCP 工具 schema、用户提示词和启用 Skills 的估算体量,并返回风险级别、告警和收窄建议。适用于用户提到 AI 回复变慢、上下文过大、表结构带太多、工具结果过长、模型开始乱答或复杂任务前需要判断是否应拆小上下文时优先调用。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sessionId: { type: "string", description: "可选,指定要诊断的 AI 会话 ID;不传时读取当前活动会话" },
|
||||
messageLimit: { type: "number", description: "可选,最多统计最近多少条消息,默认 40,最大 120" },
|
||||
includeDetails: { type: "boolean", description: "可选,是否返回最大消息、最大 DDL 表和最大 MCP schema 明细,默认 true" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inspect_sql_snippets",
|
||||
icon: "🧩",
|
||||
|
||||
@@ -184,6 +184,11 @@ export const BUILTIN_TOOL_FLOWS: AIBuiltinToolFlow[] = [
|
||||
steps: 'inspect_ai_message_flow -> inspect_ai_last_render_error / inspect_app_logs',
|
||||
description: '适合用户反馈回复被拆成多个气泡、工具调用后没继续回答、消息流状态不对时,先读取当前会话的真实消息结构和异常信号。',
|
||||
},
|
||||
{
|
||||
title: '诊断 AI 上下文体量',
|
||||
steps: 'inspect_ai_context_budget -> inspect_ai_context / inspect_ai_message_flow / inspect_ai_tool_catalog',
|
||||
description: '适合用户反馈 AI 变慢、乱答、上下文太大、工具结果过长或表结构挂太多时,先看消息、DDL、MCP schema、提示词和 Skills 的体量来源,再决定收窄上下文或拆任务。',
|
||||
},
|
||||
{
|
||||
title: '复用历史 SQL',
|
||||
steps: 'inspect_saved_queries -> get_columns / execute_sql',
|
||||
|
||||
@@ -162,6 +162,14 @@ describe('aiToolRegistry', () => {
|
||||
expect(info?.tool.function.description).toContain('连续 assistant 消息');
|
||||
});
|
||||
|
||||
it('registers the ai-context-budget inspector as a builtin tool', () => {
|
||||
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_context_budget');
|
||||
expect(info).toBeTruthy();
|
||||
expect(info?.desc).toContain('上下文体量');
|
||||
expect(info?.tool.function.description).toContain('MCP 工具 schema');
|
||||
expect(info?.tool.function.parameters?.properties?.messageLimit?.description).toContain('最大 120');
|
||||
});
|
||||
|
||||
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 sqlEditorTransactionTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_editor_transaction');
|
||||
@@ -236,6 +244,7 @@ describe('aiToolRegistry', () => {
|
||||
expect(tools.some((item) => item.function.name === 'inspect_recent_connection_failures')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_ai_last_render_error')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_ai_message_flow')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_ai_context_budget')).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);
|
||||
|
||||
@@ -137,11 +137,13 @@ describe('mcpClientInstallStatus helpers', () => {
|
||||
expect(guide).toContain('allowMutating=true');
|
||||
expect(guide).toContain('"type": "streamable-http"');
|
||||
expect(guide).toContain('"Authorization": "Bearer <随机token>"');
|
||||
expect(guide).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token>');
|
||||
expect(guide).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>');
|
||||
});
|
||||
|
||||
it('builds remote quick-start snippets for cloud agents without database secrets', () => {
|
||||
const quickStart = buildRemoteMCPClientQuickStart({
|
||||
client: 'hermans',
|
||||
displayName: 'OpenClaw',
|
||||
});
|
||||
|
||||
@@ -150,6 +152,7 @@ describe('mcpClientInstallStatus helpers', () => {
|
||||
expect(quickStart.configJson).toContain('"url": "https://<你的域名或隧道地址>/mcp"');
|
||||
expect(quickStart.configJson).toContain('"Authorization": "Bearer <随机token>"');
|
||||
expect(quickStart.configJson).not.toContain('password');
|
||||
expect(quickStart.configCommand).toBe('GoNavi.exe mcp-server remote-config --client hermans --url https://<你的域名或隧道地址>/mcp --token <随机token>');
|
||||
expect(quickStart.launchCommand).toBe('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>');
|
||||
expect(quickStart.standaloneCommand).toBe('gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>');
|
||||
expect(quickStart.verificationSteps.join('\n')).toContain('get_connections');
|
||||
|
||||
@@ -11,6 +11,7 @@ const DEFAULT_REMOTE_MCP_PATH = '/mcp';
|
||||
export interface RemoteMCPClientQuickStart {
|
||||
displayName: string;
|
||||
configJson: string;
|
||||
configCommand: string;
|
||||
launchCommand: string;
|
||||
standaloneCommand: string;
|
||||
verificationSteps: string[];
|
||||
@@ -170,7 +171,7 @@ export const formatMCPLaunchCommand = (
|
||||
};
|
||||
|
||||
export const buildRemoteMCPClientGuide = (
|
||||
status?: Pick<AIMCPClientInstallStatus, 'displayName' | 'message'> | null,
|
||||
status?: Partial<Pick<AIMCPClientInstallStatus, 'client' | 'displayName' | 'message'>> | null,
|
||||
): string => {
|
||||
const quickStart = buildRemoteMCPClientQuickStart(status);
|
||||
return [
|
||||
@@ -194,6 +195,9 @@ export const buildRemoteMCPClientGuide = (
|
||||
'可复制配置片段(适用于支持 mcpServers JSON 的 Agent):',
|
||||
...quickStart.configJson.split('\n'),
|
||||
'',
|
||||
'无 GUI / CLI 生成配置命令:',
|
||||
quickStart.configCommand,
|
||||
'',
|
||||
'CLI / 服务启动命令:',
|
||||
quickStart.launchCommand,
|
||||
`或设置环境变量:GONAVI_MCP_HTTP_TOKEN=<随机token> 后运行 ${quickStart.standaloneCommand.replace(' --token <随机token>', '')}`,
|
||||
@@ -203,11 +207,13 @@ export const buildRemoteMCPClientGuide = (
|
||||
};
|
||||
|
||||
export const buildRemoteMCPClientQuickStart = (
|
||||
status?: Pick<AIMCPClientInstallStatus, 'displayName'> | null,
|
||||
status?: Partial<Pick<AIMCPClientInstallStatus, 'client' | 'displayName'>> | null,
|
||||
): RemoteMCPClientQuickStart => {
|
||||
const displayName = String(status?.displayName || '远程 Agent').trim();
|
||||
const client = isMCPClientKey(String(status?.client || '')) ? String(status?.client || '').trim() : 'openclaw';
|
||||
const launchCommand = `GoNavi.exe mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token>`;
|
||||
const standaloneCommand = `gonavi-mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token>`;
|
||||
const configCommand = `GoNavi.exe mcp-server remote-config --client ${client} --url ${DEFAULT_REMOTE_MCP_PUBLIC_URL} --token <随机token>`;
|
||||
const configJson = JSON.stringify({
|
||||
mcpServers: {
|
||||
gonavi: {
|
||||
@@ -223,6 +229,7 @@ export const buildRemoteMCPClientQuickStart = (
|
||||
return {
|
||||
displayName,
|
||||
configJson,
|
||||
configCommand,
|
||||
launchCommand,
|
||||
standaloneCommand,
|
||||
verificationSteps: [
|
||||
|
||||
Reference in New Issue
Block a user