mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-17 03:59:41 +08:00
✨ feat(ai-tools): 新增当前连接探针并拆分 AIChatPanel 运行时模块
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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('盘点当前工作区');
|
||||
|
||||
@@ -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',
|
||||
|
||||
109
frontend/src/components/ai/AIMessageRenderBoundary.tsx
Normal file
109
frontend/src/components/ai/AIMessageRenderBoundary.tsx
Normal 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;
|
||||
109
frontend/src/components/ai/aiConnectionInsights.test.ts
Normal file
109
frontend/src/components/ai/aiConnectionInsights.test.ts
Normal 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: '当前没有活动连接',
|
||||
});
|
||||
});
|
||||
});
|
||||
84
frontend/src/components/ai/aiConnectionInsights.ts
Normal file
84
frontend/src/components/ai/aiConnectionInsights.ts
Normal 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 || '',
|
||||
};
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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「结构审查」');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 执行日志',
|
||||
|
||||
28
frontend/src/utils/aiChatRuntime.test.ts
Normal file
28
frontend/src/utils/aiChatRuntime.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
90
frontend/src/utils/aiChatRuntime.ts
Normal file
90
frontend/src/utils/aiChatRuntime.ts
Normal 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;
|
||||
};
|
||||
32
frontend/src/utils/aiToolRegistry.test.ts
Normal file
32
frontend/src/utils/aiToolRegistry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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: "📍",
|
||||
|
||||
Reference in New Issue
Block a user