feat(ai): 新增 Redis 拓扑诊断探针

- 新增 inspect_redis_topology 内置工具,输出 Redis 单机、Sentinel、Cluster 脱敏拓扑与风险提示

- 接入本地工具执行链、工具目录、系统引导和工具调用状态文案

- 补充工具注册、目录渲染、执行器和拓扑规则测试
This commit is contained in:
Syngnat
2026-06-12 03:55:26 +08:00
parent 03e08bec32
commit d5d4d4fabc
10 changed files with 428 additions and 0 deletions

View File

@@ -73,6 +73,9 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('inspect_connection_capabilities');
expect(markup).toContain('盘点本地连接资产');
expect(markup).toContain('inspect_saved_connections');
expect(markup).toContain('诊断 Redis 单机/哨兵/集群配置');
expect(markup).toContain('inspect_redis_topology');
expect(markup).toContain('Sentinel master');
expect(markup).toContain('盘点外部 SQL 目录');
expect(markup).toContain('inspect_external_sql_directories');
expect(markup).toContain('读取外部 SQL 文件');

View File

@@ -162,4 +162,58 @@ describe('aiLocalToolExecutor connection inspection tools', () => {
expect(result.content).toContain('"useSSH":true');
expect(result.content).not.toContain('分析仓库');
});
it('returns a Redis topology snapshot with Sentinel and Cluster risks', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_redis_topology', {
keyword: '订单',
}),
connections: [
{
id: 'redis-sentinel',
name: '订单 Redis Sentinel',
config: {
type: 'redis',
host: 'sentinel-a.local',
port: 6379,
hosts: ['sentinel-b.local:26379'],
topology: 'sentinel',
user: 'app',
password: 'redis-secret',
redisSentinelPassword: 'sentinel-secret',
},
hasRedisSentinelPassword: true,
},
{
id: 'redis-cluster',
name: '缓存集群',
config: {
type: 'redis',
host: '10.10.1.10',
port: 6379,
user: '',
topology: 'cluster',
hosts: ['10.10.1.11:6379'],
redisDB: 3,
useSSH: true,
},
},
],
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"totalRedisConnections":2');
expect(result.content).toContain('"totalMatched":1');
expect(result.content).toContain('"topology":"sentinel"');
expect(result.content).toContain('Sentinel master 名称为空');
expect(result.content).toContain('Sentinel 主地址端口是 6379');
expect(result.content).not.toContain('redis-secret');
expect(result.content).not.toContain('sentinel-secret');
});
});

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import type { SavedConnection } from '../../types';
import { buildRedisTopologySnapshot } from './aiRedisTopologyInsights';
const buildRedisConnection = (
id: string,
name: string,
config: Partial<SavedConnection['config']>,
extra: Partial<SavedConnection> = {},
): SavedConnection => ({
id,
name,
config: {
type: 'redis',
host: '127.0.0.1',
port: 6379,
user: '',
...config,
},
...extra,
});
describe('buildRedisTopologySnapshot', () => {
it('summarizes Redis Sentinel settings without exposing passwords', () => {
const snapshot = buildRedisTopologySnapshot({
connections: [
buildRedisConnection('redis-sentinel', '生产 Redis Sentinel', {
host: 'sentinel-a.local',
port: 6379,
hosts: ['sentinel-b.local:26379'],
topology: 'sentinel',
user: 'app',
password: 'redis-secret',
redisSentinelUser: 'sentinel-user',
redisSentinelPassword: 'sentinel-secret',
redisDB: 2,
useSSL: true,
sslMode: 'required',
}, {
hasRedisSentinelPassword: true,
}),
],
keyword: '生产',
});
expect(snapshot.totalRedisConnections).toBe(1);
expect(snapshot.totalMatched).toBe(1);
expect(snapshot.topologyBreakdown).toEqual({ sentinel: 1 });
expect(snapshot.connections[0].sentinelMaster).toBe('');
expect(snapshot.connections[0].hasRedisAuth).toBe(true);
expect(snapshot.connections[0].hasSentinelAuth).toBe(true);
expect(snapshot.connections[0].warnings).toContain('Sentinel master 名称为空go-redis FailoverClient 无法发现主节点');
expect(snapshot.connections[0].warnings).toContain('Sentinel 主地址端口是 6379请确认这里填写的是 Sentinel 端口,常见默认值是 26379');
expect(JSON.stringify(snapshot)).not.toContain('redis-secret');
expect(JSON.stringify(snapshot)).not.toContain('sentinel-secret');
});
it('reports cluster logical-db and SSH risks', () => {
const snapshot = buildRedisTopologySnapshot({
connections: [
buildRedisConnection('redis-cluster', '订单 Redis Cluster', {
host: '10.10.1.10',
port: 6379,
hosts: ['10.10.1.11:6379', '10.10.1.12:6379'],
topology: 'cluster',
redisDB: 4,
useSSH: true,
}, {
includeRedisDatabases: [0, 4],
}),
],
connectionId: 'redis-cluster',
});
expect(snapshot.connections[0].topology).toBe('cluster');
expect(snapshot.connections[0].seedAddressCount).toBe(3);
expect(snapshot.connections[0].warnings).toContain('Redis Cluster 当前后端不支持 SSH 隧道,请改用直连、代理或远程 MCP HTTP 方案');
expect(snapshot.connections[0].warnings).toContain('Redis Cluster 物理上只支持 db0GoNavi 会用 __gonavi_db_N__: 前缀模拟逻辑库隔离');
expect(snapshot.connections[0].recommendations?.join('\n')).toContain('种子节点');
});
it('filters out non-Redis connections and warns about multi-host single mode', () => {
const snapshot = buildRedisTopologySnapshot({
connections: [
buildRedisConnection('redis-single', '缓存单机', {
host: 'redis.local',
port: 6379,
topology: 'single',
hosts: ['redis-2.local:6379'],
}),
{
id: 'mysql-1',
name: '业务库',
config: {
type: 'mysql',
host: 'mysql.local',
port: 3306,
user: 'root',
},
},
],
});
expect(snapshot.totalRedisConnections).toBe(1);
expect(snapshot.connections).toHaveLength(1);
expect(snapshot.connections[0].warnings).toContain('单机模式下存在多个节点地址,后端会按多节点集群路径处理,建议显式改为 Cluster 模式');
});
});

View File

@@ -0,0 +1,207 @@
import type { SavedConnection } from '../../types';
const normalizeText = (input: unknown): string => String(input || '').trim();
const normalizeLowerText = (input: unknown): string => normalizeText(input).toLowerCase();
const normalizeLimit = (input: unknown, fallback: number, max: number): number => {
const value = Math.floor(Number(input) || fallback);
if (value < 1) return 1;
if (value > max) return max;
return value;
};
const normalizeRedisTopology = (input: unknown): 'single' | 'cluster' | 'sentinel' => {
const value = normalizeLowerText(input);
if (value === 'cluster') return 'cluster';
if (value === 'sentinel') return 'sentinel';
return 'single';
};
const buildSeedAddresses = (connection: SavedConnection): string[] => {
const config = connection.config || {};
const defaultPort = Number.isFinite(Number(config.port)) ? Number(config.port) : 6379;
const primary = normalizeText(config.host)
? `${normalizeText(config.host)}:${defaultPort}`
: '';
const extraHosts = Array.isArray(config.hosts)
? config.hosts.map((host) => normalizeText(host)).filter(Boolean)
: [];
return [primary, ...extraHosts].filter(Boolean);
};
const matchesKeyword = (keyword: string, connection: SavedConnection, seedAddresses: string[]): boolean => {
if (!keyword) return true;
const config = connection.config || {};
return [
connection.id,
connection.name,
config.type,
config.host,
config.user,
config.database,
config.topology,
config.redisSentinelMaster,
config.redisSentinelUser,
...seedAddresses,
].some((field) => normalizeLowerText(field).includes(keyword));
};
const buildRedisTopologyWarnings = (connection: SavedConnection, seedAddresses: string[]): string[] => {
const config = connection.config || {};
const topology = normalizeRedisTopology(config.topology);
const warnings: string[] = [];
if (!normalizeText(config.host)) {
warnings.push('主机地址为空,连接前需要填写 Redis 节点或 Sentinel 地址');
}
if ((topology === 'cluster' || topology === 'sentinel') && config.useSSH === true) {
warnings.push(`${topology === 'cluster' ? 'Redis Cluster' : 'Redis Sentinel'} 当前后端不支持 SSH 隧道,请改用直连、代理或远程 MCP HTTP 方案`);
}
if (topology === 'sentinel') {
if (!normalizeText(config.redisSentinelMaster)) {
warnings.push('Sentinel master 名称为空go-redis FailoverClient 无法发现主节点');
}
if (seedAddresses.length < 2) {
warnings.push('Sentinel 只配置了一个节点,建议至少填写 2-3 个 Sentinel 地址以避免单点失败');
}
if (Number(config.port) === 6379) {
warnings.push('Sentinel 主地址端口是 6379请确认这里填写的是 Sentinel 端口,常见默认值是 26379');
}
}
if (topology === 'cluster') {
if (seedAddresses.length < 2) {
warnings.push('Cluster 只配置了一个种子节点,建议填写多个 master/replica 节点提高发现成功率');
}
const redisDB = Number(config.redisDB || 0);
const includeRedisDatabases = Array.isArray(connection.includeRedisDatabases)
? connection.includeRedisDatabases.filter((item) => typeof item === 'number')
: [];
if (redisDB > 0 || includeRedisDatabases.some((item) => item > 0)) {
warnings.push('Redis Cluster 物理上只支持 db0GoNavi 会用 __gonavi_db_N__: 前缀模拟逻辑库隔离');
}
if (normalizeText(config.redisSentinelMaster) || normalizeText(config.redisSentinelUser)) {
warnings.push('Cluster 模式下 Sentinel master / Sentinel 用户字段不会生效');
}
}
if (topology === 'single') {
if (seedAddresses.length > 1) {
warnings.push('单机模式下存在多个节点地址,后端会按多节点集群路径处理,建议显式改为 Cluster 模式');
}
if (normalizeText(config.redisSentinelMaster) || normalizeText(config.redisSentinelUser)) {
warnings.push('单机模式下 Sentinel 字段不会生效,如需哨兵发现请切换为 Sentinel 模式');
}
}
return warnings;
};
const buildRedisTopologyRecommendations = (connection: SavedConnection, warnings: string[]): string[] => {
const config = connection.config || {};
const topology = normalizeRedisTopology(config.topology);
const recommendations: string[] = [];
if (topology === 'sentinel') {
recommendations.push('确认主机和附加节点填写的是 Sentinel 地址,不是 Redis master 地址');
recommendations.push('分别填写 Redis 数据节点账号密码和 Sentinel 自身账号密码,二者不要混用');
} else if (topology === 'cluster') {
recommendations.push('优先配置 2 个以上种子节点,并确认这些节点属于同一个 Redis Cluster');
recommendations.push('如果需要多库视图,优先在业务 key 上显式使用命名空间,避免误解 Cluster 的物理 db0 限制');
} else {
recommendations.push('单机模式只填写一个 Redis 地址;如果有多个节点,请改用 Cluster 或 Sentinel 模式');
}
if (warnings.some((warning) => warning.includes('SSH'))) {
recommendations.push('跨网络访问 Redis Cluster/Sentinel 时优先使用网络代理、VPN 或 GoNavi MCP HTTP而不是单端口 SSH 隧道');
}
if (config.useSSL === true) {
recommendations.push('已启用 TLS连接失败时优先核对 sslMode、CA/证书路径和服务端 SNI');
}
return recommendations;
};
export const buildRedisTopologySnapshot = (params: {
connections: SavedConnection[];
connectionId?: unknown;
keyword?: unknown;
limit?: unknown;
includeRecommendations?: unknown;
}) => {
const {
connections,
connectionId,
keyword,
limit,
includeRecommendations,
} = params;
const safeConnectionId = normalizeText(connectionId);
const safeKeyword = normalizeLowerText(keyword);
const safeLimit = normalizeLimit(limit, 20, 100);
const shouldIncludeRecommendations = includeRecommendations !== false;
const redisConnections = connections.filter((connection) =>
normalizeLowerText(connection.config?.type) === 'redis',
);
const matchedConnections = redisConnections.filter((connection) => {
if (safeConnectionId && connection.id !== safeConnectionId) {
return false;
}
return matchesKeyword(safeKeyword, connection, buildSeedAddresses(connection));
});
const topologyBreakdown = matchedConnections.reduce<Record<string, number>>((acc, connection) => {
const key = normalizeRedisTopology(connection.config?.topology);
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
const visibleConnections = matchedConnections.slice(0, safeLimit).map((connection) => {
const config = connection.config || {};
const topology = normalizeRedisTopology(config.topology);
const seedAddresses = buildSeedAddresses(connection);
const warnings = buildRedisTopologyWarnings(connection, seedAddresses);
const includeRedisDatabases = Array.isArray(connection.includeRedisDatabases)
? connection.includeRedisDatabases.filter((item) => typeof item === 'number')
: [];
return {
id: connection.id,
name: connection.name,
topology,
host: normalizeText(config.host),
port: Number.isFinite(Number(config.port)) ? Number(config.port) : null,
seedAddresses,
seedAddressCount: seedAddresses.length,
redisDB: typeof config.redisDB === 'number' ? config.redisDB : 0,
includeRedisDatabases,
useSSL: config.useSSL === true,
sslMode: config.sslMode || '',
useSSH: config.useSSH === true,
useProxy: config.useProxy === true,
useHttpTunnel: config.useHttpTunnel === true,
sentinelMaster: topology === 'sentinel' ? normalizeText(config.redisSentinelMaster) : '',
hasRedisAuth: Boolean(normalizeText(config.user) || normalizeText(config.password) || connection.hasPrimaryPassword === true),
hasSentinelAuth: topology === 'sentinel'
? Boolean(normalizeText(config.redisSentinelUser) || normalizeText(config.redisSentinelPassword) || connection.hasRedisSentinelPassword === true)
: false,
warnings,
recommendations: shouldIncludeRecommendations
? buildRedisTopologyRecommendations(connection, warnings)
: undefined,
};
});
const warningCount = visibleConnections.reduce((total, connection) => total + connection.warnings.length, 0);
return {
connectionId: safeConnectionId,
keyword: safeKeyword,
limit: safeLimit,
totalRedisConnections: redisConnections.length,
totalMatched: matchedConnections.length,
returnedConnections: visibleConnections.length,
truncated: matchedConnections.length > visibleConnections.length,
topologyBreakdown,
warningCount,
connections: visibleConnections,
};
};

View File

@@ -8,6 +8,7 @@ import { buildAIContextSnapshot } from './aiContextInsights';
import { buildConnectionCapabilitiesSnapshot } from './aiConnectionCapabilitiesInsights';
import { buildCurrentConnectionSnapshot } from './aiConnectionInsights';
import { buildSavedConnectionsSnapshot } from './aiSavedConnectionInsights';
import { buildRedisTopologySnapshot } from './aiRedisTopologyInsights';
import { buildExternalSQLFileSnapshot } from './aiExternalSqlFileInsights';
import { buildExternalSQLDirectoriesSnapshot } from './aiExternalSqlInsights';
import { findBestMatchingExternalSQLDirectory } from './aiExternalSqlPathUtils';
@@ -75,6 +76,17 @@ export async function executeConnectionWorkspaceSnapshotToolCall({
})),
success: true,
};
case 'inspect_redis_topology':
return {
content: JSON.stringify(buildRedisTopologySnapshot({
connections,
connectionId: args.connectionId,
keyword: args.keyword,
limit: args.limit,
includeRecommendations: args.includeRecommendations,
})),
success: true,
};
case 'inspect_external_sql_directories':
return {
content: JSON.stringify(buildExternalSQLDirectoriesSnapshot({

View File

@@ -163,6 +163,7 @@ export async function executeSnapshotInspectionToolCall(
inspect_current_connection: '读取当前连接失败',
inspect_connection_capabilities: '读取当前连接能力矩阵失败',
inspect_saved_connections: '读取本地连接清单失败',
inspect_redis_topology: '读取 Redis 拓扑配置失败',
inspect_external_sql_directories: '读取外部 SQL 目录失败',
inspect_external_sql_file: '读取外部 SQL 文件失败',
inspect_ai_sessions: '读取本地 AI 会话清单失败',

View File

@@ -194,6 +194,12 @@ export const appendDatabaseInspectionGuidanceMessages = (
'inspect_saved_connections',
'如果用户提到“本地存了哪些连接”“帮我找 mysql / postgres / redis 连接”“哪条连接配了 SSH/代理”,优先调用 inspect_saved_connections 读取真实本地连接清单,再决定继续查看哪条连接。',
);
appendGuidanceIfToolAvailable(
messages,
availableToolNames,
'inspect_redis_topology',
'如果用户提到“Redis 哨兵/集群”“Sentinel master”“Redis Cluster 多库”“切换 Redis DB 失败”“Redis 多节点怎么填”,优先调用 inspect_redis_topology 读取真实 Redis 拓扑、节点、认证状态和风险提示,不要凭默认端口或经验猜测。',
);
appendGuidanceIfToolAvailable(
messages,
availableToolNames,

View File

@@ -49,6 +49,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
inspect_current_connection: '读取当前连接摘要',
inspect_connection_capabilities: '读取当前连接能力矩阵',
inspect_saved_connections: '盘点本地已保存连接',
inspect_redis_topology: '诊断 Redis 拓扑配置',
inspect_external_sql_directories: '盘点外部 SQL 目录',
inspect_external_sql_file: '读取外部 SQL 文件',
inspect_ai_sessions: '盘点本地 AI 历史会话',

View File

@@ -104,6 +104,31 @@ export const BUILTIN_AI_INSPECTION_CONTEXT_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_redis_topology",
icon: "🧰",
desc: "诊断 Redis 单机/哨兵/集群配置",
detail:
"读取本地 Redis 连接拓扑摘要返回单机、Sentinel、Cluster 的节点、master、认证状态、DB 范围和风险提示。适合用户问 Redis 哨兵/集群怎么配、为什么切库后失败、Cluster 多 DB 怎么处理时先读真实配置。",
params: "connectionId?, keyword?, limit?, includeRecommendations?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_redis_topology",
description:
"读取本地 Redis 连接的单机、Sentinel、Cluster 拓扑配置摘要返回节点列表、Sentinel master、认证状态、DB 选择、TLS/SSH/代理状态、潜在配置风险和建议。适用于用户提到 Redis 哨兵、Redis Cluster、切换数据库失败、多节点地址、Sentinel master、Cluster 逻辑库或跨网络访问 Redis 时,先读取真实连接配置再回答;结果不会回显 Redis 密码或 Sentinel 密码。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "可选,只诊断某个 Redis 连接 ID" },
keyword: { type: "string", description: "可选按连接名、地址、拓扑、Sentinel master 或节点地址筛选" },
limit: { type: "number", description: "可选,最多返回多少条 Redis 连接,默认 20最大 100" },
includeRecommendations: { type: "boolean", description: "可选,是否返回修复建议,默认 true" },
},
},
},
},
},
{
name: "inspect_external_sql_directories",
icon: "🗂️",

View File

@@ -147,6 +147,15 @@ describe('aiToolRegistry', () => {
expect(info?.tool.function.description).toContain('本地已保存连接清单');
});
it('registers the Redis topology inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_redis_topology');
expect(info).toBeTruthy();
expect(info?.desc).toContain('Redis 单机/哨兵/集群');
expect(info?.tool.function.description).toContain('Sentinel');
expect(info?.tool.function.description).toContain('不会回显 Redis 密码');
expect(info?.tool.function.parameters?.properties?.connectionId?.description).toContain('Redis 连接 ID');
});
it('registers the external-sql-directory inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_external_sql_directories');
expect(info).toBeTruthy();
@@ -280,6 +289,7 @@ describe('aiToolRegistry', () => {
expect(tools.some((item) => item.function.name === 'inspect_current_connection')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_connection_capabilities')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_saved_connections')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_redis_topology')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_external_sql_directories')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_external_sql_file')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_recent_sql_activity')).toBe(true);