feat(ai-tools): 新增当前连接探针并拆分 AIChatPanel 运行时模块

This commit is contained in:
Syngnat
2026-06-08 18:53:12 +08:00
parent 7cce1ce30a
commit f19ff5fdd5
16 changed files with 569 additions and 192 deletions

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { compressContextIfNeeded, getDynamicMaxContextChars, sanitizeErrorMsg } from './aiChatRuntime';
describe('aiChatRuntime', () => {
it('maps modern model families to practical context windows', () => {
expect(getDynamicMaxContextChars('gemini-2.5-pro')).toBe(5000000);
expect(getDynamicMaxContextChars('gpt-5')).toBe(1000000);
expect(getDynamicMaxContextChars('claude-4-sonnet')).toBe(1000000);
expect(getDynamicMaxContextChars('gpt-4o')).toBe(128000);
expect(getDynamicMaxContextChars()).toBe(258000);
});
it('sanitizes html gateway errors and truncates oversized plain text errors', () => {
expect(sanitizeErrorMsg('<html><head><title>502 Bad Gateway</title></head></html>')).toBe('HTTP 502: 502 Bad Gateway');
expect(sanitizeErrorMsg('x'.repeat(320))).toBe(`${'x'.repeat(280)}...(已截断)`);
expect(sanitizeErrorMsg('permission denied')).toBe('permission denied');
});
it('skips compression when the payload is still within the configured limit', async () => {
const result = await compressContextIfNeeded('session-1', [
{ role: 'user', content: 'short prompt' },
{ role: 'assistant', content: 'short answer' },
], 1000);
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,90 @@
import { useStore } from '../store';
const genCompressionMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
export const getDynamicMaxContextChars = (modelName?: string) => {
if (!modelName) return 258000;
const lower = modelName.toLowerCase();
if (lower.includes('gemini-1.5-pro') || lower.includes('gemini-2') || lower.includes('gemini-3')) {
return 5000000;
}
if (lower.includes('glm-5') || lower.includes('claude-4') || lower.includes('claude-3.7') || lower.includes('gpt-5') || lower.includes('qwen3') || lower.includes('deepseek-v4')) {
return 1000000;
}
if (lower.includes('claude-3-opus') || lower.includes('claude-3.5') || lower.includes('glm-4-long') || lower.includes('qwen-long')) {
return 1000000;
}
if (lower.includes('claude') || lower.includes('deepseek') || lower.includes('gpt-4.5') || lower.includes('qwen2.5')) {
return 258000;
}
if (lower.includes('gpt-4') || lower.includes('gpt-4o') || lower.includes('glm') || lower.includes('z-ai')) {
return 128000;
}
if (lower.includes('qwen')) {
return 128000;
}
return 258000;
};
export const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number) => {
try {
const chars = messagesPayload.reduce((sum, message) =>
sum + (message.content?.length || 0) + (message.reasoning_content?.length || 0) + JSON.stringify(message.tool_calls || []).length, 0);
if (chars < maxLimit) return null;
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AIChatSend) return null;
const connectingMsgId = genCompressionMessageId();
useStore.getState().addAIChatMessage(sid, {
id: connectingMsgId,
role: 'assistant',
phase: 'connecting',
content: '⚙️ 对话已超载,正在启动记忆压缩...',
timestamp: Date.now(),
loading: true,
});
const summaryPrompt = `这是一段超长对话的历史记录。为了释放上下文空间同时保留你的记忆核心,请你仔细阅读并以“技术事实、已探索出的数据结构状态、用户的中心诉求、当前进展”为准则,进行高度浓缩的结构化总结。
注意:
1. 客观准确,不能遗漏关键业务逻辑或探索出的表名/字段。
2. 剔除无效执行过程、客套话、JSON返回值本身。
3. 请控制在 1000-2000 字左右,输出纯干货 Markdown。
4. 开头直接输出总结,不要带寒暄。`;
const result = await Service.AIChatSend([
{ role: 'system', content: summaryPrompt },
...messagesPayload,
]);
if (result?.success && result.content) {
useStore.getState().deleteAIChatMessage(sid, connectingMsgId);
return result.content;
}
useStore.getState().updateAIChatMessage(sid, connectingMsgId, {
loading: false,
phase: 'idle',
content: '❌ 记忆压缩失败,将尝试原样接续...',
});
} catch (error) {
console.error('Compression exception:', error);
}
return null;
};
export const sanitizeErrorMsg = (raw: string): string => {
if (!raw || typeof raw !== 'string') return '未知错误';
if (raw.includes('<html') || raw.includes('<!DOCTYPE') || raw.includes('<head')) {
const titleMatch = raw.match(/<title[^>]*>([^<]+)<\/title>/i);
const codeMatch = raw.match(/\b(4\d{2}|5\d{2})\b/);
const title = titleMatch?.[1]?.trim();
const code = codeMatch?.[1];
if (title) return code ? `HTTP ${code}: ${title}` : title;
if (code) return `HTTP ${code} 服务端错误`;
return '服务端返回了异常 HTML 响应(可能是网关超时或服务不可用)';
}
if (raw.length > 300) return `${raw.substring(0, 280)}...(已截断)`;
return raw;
};

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { BUILTIN_AI_TOOL_INFO, buildAvailableAIChatTools } from './aiToolRegistry';
describe('aiToolRegistry', () => {
it('registers the current-connection inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_current_connection');
expect(info).toBeTruthy();
expect(info?.desc).toContain('当前活动连接');
expect(info?.tool.function.description).toContain('SSH/代理/HTTP 隧道状态');
});
it('keeps builtin tools and MCP tools in the unified runtime tool chain', () => {
const tools = buildAvailableAIChatTools([{
alias: 'custom_probe',
originalName: 'custom_probe',
serverId: 'server-1',
serverName: 'demo',
title: '自定义探针',
description: '读取额外环境信息',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
}]);
expect(tools.some((item) => item.function.name === 'inspect_current_connection')).toBe(true);
expect(tools.some((item) => item.function.name === 'custom_probe')).toBe(true);
});
});

View File

@@ -332,6 +332,23 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_current_connection",
icon: "🛰️",
desc: "查看当前活动连接/数据源摘要",
detail:
"返回当前活动连接的类型、地址、端口、当前数据库、是否启用 SSH/代理/HTTP 隧道,以及当前活动页签绑定的表信息。适合用户问“我现在连的是哪个库”“这个连接走没走 SSH”“当前数据源是什么类型”时先读取真实连接状态。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_current_connection",
description:
"读取当前活动连接或当前页签对应数据源的真实摘要包括连接类型、地址、端口、当前数据库、SSH/代理/HTTP 隧道状态,以及当前页签绑定的表上下文。适用于用户提到当前连接、当前数据源、当前库地址、是否走 SSH、当前连的是哪种数据库时先读取真实界面上下文避免模型猜测。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_active_tab",
icon: "📍",