diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index dbbb3cb..182f983 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -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(' { 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'); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index fad542c..174697c 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -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 ( -
-
-
- 这条 AI 消息渲染失败,已自动隔离 -
-
- 其余对话仍可继续使用。你可以先删除这条异常消息,再继续操作。 -
-
- {this.state.error?.message || '未知渲染错误'} -
-
- - -
-
-
- ); - } - - 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(' 内容 - const titleMatch = raw.match(/]*>([^<]+)<\/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: '', diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index c9e5e64..3f60a97 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -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('盘点当前工作区'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index ef35726..5cafd5b 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -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', diff --git a/frontend/src/components/ai/AIMessageRenderBoundary.tsx b/frontend/src/components/ai/AIMessageRenderBoundary.tsx new file mode 100644 index 0000000..8243e42 --- /dev/null +++ b/frontend/src/components/ai/AIMessageRenderBoundary.tsx @@ -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 ( +
+
+
+ 这条 AI 消息渲染失败,已自动隔离 +
+
+ 其余对话仍可继续使用。你可以先删除这条异常消息,再继续操作。 +
+
+ {this.state.error?.message || '未知渲染错误'} +
+
+ + +
+
+
+ ); + } + + return this.props.children; + } +} + +export default AIMessageRenderBoundary; diff --git a/frontend/src/components/ai/aiConnectionInsights.test.ts b/frontend/src/components/ai/aiConnectionInsights.test.ts new file mode 100644 index 0000000..9b19b70 --- /dev/null +++ b/frontend/src/components/ai/aiConnectionInsights.test.ts @@ -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: '当前没有活动连接', + }); + }); +}); diff --git a/frontend/src/components/ai/aiConnectionInsights.ts b/frontend/src/components/ai/aiConnectionInsights.ts new file mode 100644 index 0000000..0be9c5b --- /dev/null +++ b/frontend/src/components/ai/aiConnectionInsights.ts @@ -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 || '', + }; +}; diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index acd3300..42b7448 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -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({ diff --git a/frontend/src/components/ai/aiLocalToolExecutor.ts b/frontend/src/components/ai/aiLocalToolExecutor.ts index 9858d53..2c8abf8 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.ts @@ -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({ diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index 000a210..aa0a2aa 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.test.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.test.ts @@ -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「结构审查」'); diff --git a/frontend/src/components/ai/aiSystemContextMessages.ts b/frontend/src/components/ai/aiSystemContextMessages.ts index 049c9c9..c620cf0 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.ts @@ -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); diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index 94bffe3..82d65ff 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -34,6 +34,7 @@ const TOOL_ACTION_LABELS: Record = { get_table_ddl: '提取建表语句', inspect_table_bundle: '抓取完整表结构快照', inspect_database_bundle: '抓取数据库结构总览', + inspect_current_connection: '读取当前连接摘要', inspect_active_tab: '读取当前活动页签', inspect_workspace_tabs: '盘点当前工作区页签', inspect_recent_sql_logs: '回看最近 SQL 执行日志', diff --git a/frontend/src/utils/aiChatRuntime.test.ts b/frontend/src/utils/aiChatRuntime.test.ts new file mode 100644 index 0000000..ae9ea25 --- /dev/null +++ b/frontend/src/utils/aiChatRuntime.test.ts @@ -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('502 Bad Gateway')).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(); + }); +}); diff --git a/frontend/src/utils/aiChatRuntime.ts b/frontend/src/utils/aiChatRuntime.ts new file mode 100644 index 0000000..801e27c --- /dev/null +++ b/frontend/src/utils/aiChatRuntime.ts @@ -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(']*>([^<]+)<\/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; +}; diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts new file mode 100644 index 0000000..e24bd07 --- /dev/null +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/aiToolRegistry.ts b/frontend/src/utils/aiToolRegistry.ts index f505602..cddd366 100644 --- a/frontend/src/utils/aiToolRegistry.ts +++ b/frontend/src/utils/aiToolRegistry.ts @@ -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: "📍",