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

@@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8');
const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8');
const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8');
const runtimeSource = readFileSync(new URL('../utils/aiChatRuntime.ts', import.meta.url), 'utf8');
describe('AIChatPanel message render isolation', () => {
it('keeps per-message render failures scoped to the broken bubble', () => {
expect(source).toContain('class AIMessageRenderBoundary extends React.Component');
expect(source).toContain("import AIMessageRenderBoundary from './ai/AIMessageRenderBoundary';");
expect(boundarySource).toContain('class AIMessageRenderBoundary extends React.Component');
expect(source).toContain('[AI Message Render Error]');
expect(source).toContain('这条 AI 消息渲染失败,已自动隔离');
expect(boundarySource).toContain('这条 AI 消息渲染失败,已自动隔离');
expect(source).toContain('__gonaviLastAIMessageRenderError');
expect(source).toContain('<AIMessageRenderBoundary');
expect(source).toContain('onDeleteMessage={handleDeleteMessage}');
@@ -33,12 +36,21 @@ describe('AIChatPanel message render isolation', () => {
expect(systemContextSource).toContain('get_indexes、get_foreign_keys、get_triggers、get_table_ddl');
expect(systemContextSource).toContain('inspect_active_tab 读取当前活动页签上下文');
expect(systemContextSource).toContain('inspect_workspace_tabs 盘点当前工作区');
expect(systemContextSource).toContain('inspect_current_connection');
expect(source).toContain('tabs: useStore.getState().tabs');
expect(source).toContain('activeTabId: useStore.getState().activeTabId');
expect(source).toContain('toolContextMap: toolContextMapRef.current');
expect(source).toContain('buildToolResultMessage');
});
it('extracts chat runtime helpers so context compression and error cleanup stay out of the panel file', () => {
expect(source).toContain('compressContextIfNeeded, getDynamicMaxContextChars, sanitizeErrorMsg');
expect(runtimeSource).toContain('export const getDynamicMaxContextChars');
expect(runtimeSource).toContain('export const compressContextIfNeeded');
expect(runtimeSource).toContain('export const sanitizeErrorMsg');
expect(runtimeSource).toContain('⚙️ 对话已超载,正在启动记忆压缩');
});
it('keeps the v2 history mode sorted by the latest updated session first', () => {
expect(source).toContain('const orderedAISessions = useMemo(');
expect(source).toContain('right.updatedAt - left.updatedAt');

View File

@@ -20,6 +20,7 @@ import { AIChatWelcome } from './ai/AIChatWelcome';
import { AIMessageBubble } from './ai/AIMessageBubble';
import { AIChatInput } from './ai/AIChatInput';
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
import AIMessageRenderBoundary from './ai/AIMessageRenderBoundary';
import AIChatPanelModeContent, { type AIChatInsightItem } from './ai/AIChatPanelModeContent';
import type { AIComposerNotice } from '../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
@@ -30,6 +31,7 @@ import {
} from '../utils/aiComposerNotice';
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
import { toAIRequestMessage } from '../utils/aiMessagePayload';
import { compressContextIfNeeded, getDynamicMaxContextChars, sanitizeErrorMsg } from '../utils/aiChatRuntime';
import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts';
import { isMacLikePlatform } from '../utils/appearance';
import { buildAvailableAIChatTools } from '../utils/aiToolRegistry';
@@ -52,195 +54,6 @@ interface AIChatPanelProps {
const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
interface AIMessageRenderBoundaryProps {
children: React.ReactNode;
msg: AIChatMessage;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
onDeleteMessage: (id: string) => void;
onError?: (error: Error, errorInfo: React.ErrorInfo, msg: AIChatMessage) => void;
}
interface AIMessageRenderBoundaryState {
hasError: boolean;
error: Error | null;
}
class AIMessageRenderBoundary extends React.Component<
AIMessageRenderBoundaryProps,
AIMessageRenderBoundaryState
> {
constructor(props: AIMessageRenderBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): AIMessageRenderBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo, this.props.msg);
}
private handleRetryRender = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
const { msg, darkMode, overlayTheme, onDeleteMessage } = this.props;
return (
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
<div style={{
background: darkMode ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
border: `1px solid ${darkMode ? 'rgba(248,113,113,0.32)' : 'rgba(239,68,68,0.18)'}`,
borderRadius: 12,
padding: '14px 16px',
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: overlayTheme.titleText }}>
AI
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: overlayTheme.mutedText }}>
使
</div>
<div style={{
marginTop: 10,
padding: '8px 10px',
borderRadius: 8,
background: darkMode ? 'rgba(0,0,0,0.18)' : 'rgba(0,0,0,0.03)',
fontSize: 12,
color: overlayTheme.titleText,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}>
{this.state.error?.message || '未知渲染错误'}
</div>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button
type="button"
onClick={this.handleRetryRender}
style={{
border: overlayTheme.sectionBorder,
background: 'transparent',
color: overlayTheme.titleText,
borderRadius: 8,
padding: '6px 12px',
cursor: 'pointer',
}}
>
</button>
<button
type="button"
onClick={() => onDeleteMessage(msg.id)}
style={{
border: '1px solid rgba(239,68,68,0.28)',
background: darkMode ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
color: '#ef4444',
borderRadius: 8,
padding: '6px 12px',
cursor: 'pointer',
}}
>
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export const getDynamicMaxContextChars = (modelName?: string) => {
if (!modelName) return 258000; // 默认 258k (2026主流基线)
const lower = modelName.toLowerCase();
// 「星际杯」- 百万到千万级 Tokens (保守取 2~5M 字符)
if (lower.includes('gemini-1.5-pro') || lower.includes('gemini-2') || lower.includes('gemini-3')) {
return 5000000;
}
// 「超大杯」- 1M Tokens (针对 2026 旗舰:约 1,000,000 字符)
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;
}
// 「大杯」- 200K ~ 258K Tokens (针对现代主流:约 258,000 字符)
if (lower.includes('claude') || lower.includes('deepseek') || lower.includes('gpt-4.5') || lower.includes('qwen2.5')) {
return 258000;
}
// 「中杯/小杯」- 128K Tokens (老基线:约 128,000 字符)
if (lower.includes('gpt-4') || lower.includes('gpt-4o') || lower.includes('glm') || lower.includes('z-ai')) {
return 128000;
}
if (lower.includes('qwen')) {
return 128000;
}
// Default fallback
return 258000;
};
// 当超出指定字符上限时触发上下文自建压缩
const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number) => {
try {
const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0);
if (chars < maxLimit) return null;
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AIChatSend) return null;
const connectingMsgId = genId();
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 sysMsg = { role: 'system', content: summaryPrompt };
const result = await Service.AIChatSend([sysMsg, ...messagesPayload]);
if (result?.success && result.content) {
useStore.getState().deleteAIChatMessage(sid, connectingMsgId);
return result.content;
} else {
useStore.getState().updateAIChatMessage(sid, connectingMsgId, { loading: false, phase: 'idle', content: '❌ 记忆压缩失败,将尝试原样接续...' });
}
} catch (e) {
console.error("Compression exception:", e);
}
return null;
};
// 清洗错误信息:去除 HTML 标签、提取关键错误描述、截断过长文本
const sanitizeErrorMsg = (raw: string): string => {
if (!raw || typeof raw !== 'string') return '未知错误';
// 检测 HTML 内容
if (raw.includes('<html') || raw.includes('<!DOCTYPE') || raw.includes('<head')) {
// 尝试提取 <title> 内容
const titleMatch = raw.match(/<title[^>]*>([^<]+)<\/title>/i);
// 尝试提取 HTTP 状态码
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;
};
const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = {
global: '',
database: '',

View File

@@ -28,6 +28,8 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('inspect_database_bundle');
expect(markup).toContain('查看当前 AI 上下文');
expect(markup).toContain('inspect_ai_context');
expect(markup).toContain('查看当前连接');
expect(markup).toContain('inspect_current_connection');
expect(markup).toContain('读取当前页签');
expect(markup).toContain('inspect_active_tab');
expect(markup).toContain('盘点当前工作区');

View File

@@ -42,6 +42,11 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'inspect_ai_context → inspect_table_bundle / get_columns',
description: '适合先确认这轮对话当前到底挂了哪些表结构,再继续做字段核对、表设计评审或 SQL 生成。',
},
{
title: '查看当前连接',
steps: 'inspect_current_connection → get_databases / get_tables',
description: '适合先确认当前活动数据源的类型、地址、当前库和 SSH/代理状态,再继续做库表探索或连接问题排查。',
},
{
title: '读取当前页签',
steps: 'inspect_active_tab → get_columns / get_indexes / execute_sql',

View File

@@ -0,0 +1,109 @@
import React from 'react';
import type { AIChatMessage } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
interface AIMessageRenderBoundaryProps {
children: React.ReactNode;
msg: AIChatMessage;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
onDeleteMessage: (id: string) => void;
onError?: (error: Error, errorInfo: React.ErrorInfo, msg: AIChatMessage) => void;
}
interface AIMessageRenderBoundaryState {
hasError: boolean;
error: Error | null;
}
export class AIMessageRenderBoundary extends React.Component<
AIMessageRenderBoundaryProps,
AIMessageRenderBoundaryState
> {
constructor(props: AIMessageRenderBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): AIMessageRenderBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo, this.props.msg);
}
private handleRetryRender = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
const { msg, darkMode, overlayTheme, onDeleteMessage } = this.props;
return (
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
<div style={{
background: darkMode ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
border: `1px solid ${darkMode ? 'rgba(248,113,113,0.32)' : 'rgba(239,68,68,0.18)'}`,
borderRadius: 12,
padding: '14px 16px',
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: overlayTheme.titleText }}>
AI
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: overlayTheme.mutedText }}>
使
</div>
<div style={{
marginTop: 10,
padding: '8px 10px',
borderRadius: 8,
background: darkMode ? 'rgba(0,0,0,0.18)' : 'rgba(0,0,0,0.03)',
fontSize: 12,
color: overlayTheme.titleText,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}>
{this.state.error?.message || '未知渲染错误'}
</div>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button
type="button"
onClick={this.handleRetryRender}
style={{
border: overlayTheme.sectionBorder,
background: 'transparent',
color: overlayTheme.titleText,
borderRadius: 8,
padding: '6px 12px',
cursor: 'pointer',
}}
>
</button>
<button
type="button"
onClick={() => onDeleteMessage(msg.id)}
style={{
border: '1px solid rgba(239,68,68,0.28)',
background: darkMode ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
color: '#ef4444',
borderRadius: 8,
padding: '6px 12px',
cursor: 'pointer',
}}
>
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default AIMessageRenderBoundary;

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import type { SavedConnection, TabData } from '../../types';
import { buildCurrentConnectionSnapshot } from './aiConnectionInsights';
const baseConnection: SavedConnection = {
id: 'conn-1',
name: '生产主库',
config: {
type: 'mysql',
host: '10.0.0.8',
port: 3306,
user: 'reader',
database: 'crm',
driver: 'mysql',
useSSH: true,
ssh: {
host: '192.168.1.20',
port: 22,
user: 'jump',
},
useProxy: true,
proxy: {
type: 'socks5',
host: '127.0.0.1',
port: 1080,
},
},
};
describe('buildCurrentConnectionSnapshot', () => {
it('returns a structured summary for the active connection and tab', () => {
const tabs: TabData[] = [{
id: 'tab-1',
title: 'orders',
type: 'table',
connectionId: 'conn-1',
dbName: 'crm',
tableName: 'orders',
readOnly: true,
}];
const snapshot = buildCurrentConnectionSnapshot({
activeContext: {
connectionId: 'conn-1',
dbName: 'crm',
},
tabs,
activeTabId: 'tab-1',
connections: [baseConnection],
});
expect(snapshot).toMatchObject({
hasActiveConnection: true,
connectionId: 'conn-1',
connectionName: '生产主库',
connectionType: 'mysql',
host: '10.0.0.8',
port: 3306,
activeDbName: 'crm',
useSSH: true,
sshHost: '192.168.1.20',
sshUser: 'jump',
useProxy: true,
proxyType: 'socks5',
activeTabId: 'tab-1',
activeTabType: 'table',
activeTableName: 'orders',
readOnly: true,
});
});
it('falls back to the active tab when no explicit active context exists', () => {
const snapshot = buildCurrentConnectionSnapshot({
activeContext: null,
tabs: [{
id: 'tab-query-1',
title: '订单排查',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
query: 'select * from orders limit 10',
}],
activeTabId: 'tab-query-1',
connections: [baseConnection],
});
expect(snapshot).toMatchObject({
hasActiveConnection: true,
connectionId: 'conn-1',
activeDbName: 'crm',
activeTabType: 'query',
});
});
it('returns an empty-state message when no active connection can be resolved', () => {
const snapshot = buildCurrentConnectionSnapshot({
activeContext: null,
tabs: [],
activeTabId: null,
connections: [baseConnection],
});
expect(snapshot).toEqual({
hasActiveConnection: false,
message: '当前没有活动连接',
});
});
});

View File

@@ -0,0 +1,84 @@
import type { SavedConnection, TabData } from '../../types';
export const buildCurrentConnectionSnapshot = (params: {
activeContext?: { connectionId: string; dbName?: string } | null;
tabs?: TabData[];
activeTabId?: string | null;
connections: SavedConnection[];
}) => {
const {
activeContext = null,
tabs = [],
activeTabId = null,
connections,
} = params;
const activeTab = tabs.find((tab) => tab.id === activeTabId);
const connectionId = String(activeContext?.connectionId || activeTab?.connectionId || '').trim();
const activeDbName = String(activeContext?.dbName || activeTab?.dbName || '').trim();
if (!connectionId) {
return {
hasActiveConnection: false,
message: '当前没有活动连接',
};
}
const connection = connections.find((item) => item.id === connectionId);
if (!connection) {
return {
hasActiveConnection: false,
connectionId,
message: '当前活动连接在本地缓存中不存在',
};
}
const config = connection.config || {};
const ssh = config.useSSH ? config.ssh : undefined;
const proxy = config.useProxy ? config.proxy : undefined;
const httpTunnel = config.useHttpTunnel ? config.httpTunnel : undefined;
const configuredHosts = Array.isArray(config.hosts)
? config.hosts.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return {
hasActiveConnection: true,
connectionId: connection.id,
connectionName: connection.name,
connectionType: config.type || '',
host: config.host || '',
port: typeof config.port === 'number' ? config.port : null,
user: config.user || '',
activeDbName: activeDbName || config.database || '',
configuredDatabase: config.database || '',
driver: config.driver || '',
topology: config.topology || 'single',
hosts: configuredHosts,
useSSL: config.useSSL === true,
sslMode: config.sslMode || '',
useSSH: config.useSSH === true,
sshHost: ssh?.host || '',
sshPort: typeof ssh?.port === 'number' ? ssh.port : null,
sshUser: ssh?.user || '',
useProxy: config.useProxy === true,
proxyType: proxy?.type || '',
proxyHost: proxy?.host || '',
proxyPort: typeof proxy?.port === 'number' ? proxy.port : null,
useHttpTunnel: config.useHttpTunnel === true,
httpTunnelHost: httpTunnel?.host || '',
httpTunnelPort: typeof httpTunnel?.port === 'number' ? httpTunnel.port : null,
hasURI: Boolean(String(config.uri || '').trim()),
hasDSN: Boolean(String(config.dsn || '').trim()),
hasConnectionParams: Boolean(String(config.connectionParams || '').trim()),
clickHouseProtocol: config.clickHouseProtocol || '',
oceanBaseProtocol: config.oceanBaseProtocol || '',
replicaSet: config.replicaSet || '',
authSource: config.authSource || '',
readPreference: config.readPreference || '',
mongoSrv: config.mongoSrv === true,
redisDB: typeof config.redisDB === 'number' ? config.redisDB : null,
readOnly: activeTab?.readOnly === true || config.jvm?.readOnly === true,
activeTabId: activeTab?.id || '',
activeTabType: activeTab?.type || '',
activeTableName: activeTab?.tableName || '',
};
};

View File

@@ -169,6 +169,58 @@ describe('aiLocalToolExecutor', () => {
expect(result.content).toContain('CREATE TABLE orders');
});
it('returns the current connection snapshot so the model can inspect host, db, and ssh state', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_current_connection', {}),
connections: [{
id: 'conn-1',
name: '主库',
config: {
type: 'mysql',
host: '10.188.101.184',
port: 1523,
user: 'glzc',
database: 'crm',
useSSH: true,
ssh: {
host: '192.168.66.28',
port: 22,
user: 'wyeye',
},
},
}],
activeContext: {
connectionId: 'conn-1',
dbName: 'crm',
},
tabs: [{
id: 'tab-query-1',
title: '订单分析',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
query: 'select * from orders limit 20',
}],
activeTabId: 'tab-query-1',
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"hasActiveConnection":true');
expect(result.content).toContain('"connectionName":"主库"');
expect(result.content).toContain('"host":"10.188.101.184"');
expect(result.content).toContain('"port":1523');
expect(result.content).toContain('"activeDbName":"crm"');
expect(result.content).toContain('"useSSH":true');
expect(result.content).toContain('"sshHost":"192.168.66.28"');
expect(result.content).toContain('"activeTabType":"query"');
});
it('blocks execute_sql when the AI safety check rejects the statement', async () => {
const query = vi.fn();
const result = await executeLocalAIToolCall({

View File

@@ -7,6 +7,7 @@ import { buildAIReadonlyPreviewSQL } from '../../utils/aiSqlLimit';
import { buildPaginatedSelectSQL, quoteQualifiedIdent } from '../../utils/sql';
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
import { buildAIContextSnapshot } from './aiContextInsights';
import { buildCurrentConnectionSnapshot } from './aiConnectionInsights';
import {
buildActiveTabSnapshot,
buildRecentSqlLogsSnapshot,
@@ -180,6 +181,20 @@ export async function executeLocalAIToolCall({
try {
const args = JSON.parse(toolCall.function.arguments || '{}');
switch (toolCall.function.name) {
case 'inspect_current_connection': {
try {
content = JSON.stringify(buildCurrentConnectionSnapshot({
activeContext,
tabs,
activeTabId,
connections,
}));
success = true;
} catch (error: any) {
content = `读取当前连接失败: ${error?.message || error}`;
}
break;
}
case 'inspect_active_tab': {
try {
content = JSON.stringify(buildActiveTabSnapshot({

View File

@@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => {
connections: [connections[0]],
tabs: [],
activeTabId: null,
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_context', 'get_columns'],
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_context', 'inspect_current_connection', 'get_columns'],
skills,
userPromptSettings,
});
@@ -76,6 +76,8 @@ describe('buildAISystemContextMessages', () => {
const joined = messages.map((message) => message.content).join('\n');
expect(joined).toContain('inspect_workspace_tabs 盘点当前工作区');
expect(joined).toContain('inspect_ai_context 读取当前挂载的表结构上下文');
expect(joined).toContain('inspect_current_connection');
expect(joined).toContain('当前连接');
expect(joined).toContain('以下是当前用户的自定义补充提示词(全局)');
expect(joined).toContain('以下是当前用户的自定义补充提示词(数据库会话)');
expect(joined).toContain('以下是当前启用的 Skill「结构审查」');

View File

@@ -309,6 +309,12 @@ SELECT * FROM users WHERE status = 1;
content: '如果用户提到“当前 AI 上下文”“当前关联了哪些表”“现在带了哪些表结构”,优先调用 inspect_ai_context 读取当前挂载的表结构上下文,不要凭记忆复述。',
});
}
if (availableToolNames.includes('inspect_current_connection')) {
systemMessages.push({
role: 'system',
content: '如果用户提到“当前连接”“当前数据源”“我现在连的是哪个库/地址”“这个连接走没走 SSH/代理”,优先调用 inspect_current_connection 读取当前活动连接摘要,不要凭界面或记忆猜测。',
});
}
appendCustomPromptGroup(systemMessages, ['database'], userPromptSettings);
appendSkillPromptGroup(systemMessages, ['database'], skills, availableToolNames);

View File

@@ -34,6 +34,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
get_table_ddl: '提取建表语句',
inspect_table_bundle: '抓取完整表结构快照',
inspect_database_bundle: '抓取数据库结构总览',
inspect_current_connection: '读取当前连接摘要',
inspect_active_tab: '读取当前活动页签',
inspect_workspace_tabs: '盘点当前工作区页签',
inspect_recent_sql_logs: '回看最近 SQL 执行日志',

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: "📍",