diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index a7b5e0b..37f3f83 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -66,6 +66,8 @@ describe('AIBuiltinToolsCatalog', () => { expect(markup).toContain('inspect_recent_sql_activity'); expect(markup).toContain('排查应用日志'); expect(markup).toContain('inspect_app_logs'); + expect(markup).toContain('排查连接失败与冷却'); + expect(markup).toContain('inspect_recent_connection_failures'); expect(markup).toContain('排查 AI 气泡渲染异常'); expect(markup).toContain('inspect_ai_last_render_error'); expect(markup).toContain('复用历史 SQL'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index c596d7c..c071e4f 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -140,6 +140,11 @@ const BUILTIN_TOOL_FLOWS = [ steps: 'inspect_app_logs → inspect_mcp_setup / inspect_saved_connections / inspect_current_connection', description: '适合先回看 gonavi.log 尾部的 ERROR/WARN,再结合 MCP、连接和当前数据源状态继续定位启动异常、连接失败或外部工具拉起问题。', }, + { + title: '排查连接失败与冷却', + steps: 'inspect_recent_connection_failures → inspect_current_connection / inspect_saved_connections / inspect_app_logs', + description: '适合用户直接问“为什么连接不上”或已经看到冷却/验证失败提示时,先拿到结构化根因、最新地址和下一步建议,再决定回到连接配置还是看更长日志。', + }, { title: '排查 AI 气泡渲染异常', steps: 'inspect_ai_last_render_error → inspect_active_tab / inspect_ai_runtime', diff --git a/frontend/src/components/ai/aiConnectionFailureInsights.test.ts b/frontend/src/components/ai/aiConnectionFailureInsights.test.ts new file mode 100644 index 0000000..1678aa5 --- /dev/null +++ b/frontend/src/components/ai/aiConnectionFailureInsights.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { buildRecentConnectionFailureSnapshot } from './aiConnectionFailureInsights'; + +describe('buildRecentConnectionFailureSnapshot', () => { + it('summarizes recent validation failures and cooldown hits from gonavi.log lines', () => { + const snapshot = buildRecentConnectionFailureSnapshot({ + readResult: { + success: true, + data: { + logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', + requestedLineLimit: 120, + lines: [ + '2026/06/06 19:42:36.037552 [INFO] 获取数据库连接:类型=mysql 地址=10.188.101.184:1523 数据库=(default) 用户=glzc 超时=30s 拓扑=single SSH=192.168.66.28:22 用户=wyeye 缓存Key=026837d0a1b6 启动阶段=稳定期(age=34.322s)', + '2026/06/06 19:42:37.088288 [ERROR] 建立数据库连接失败:类型=mysql 地址=10.188.101.184:1523 数据库=(default) 用户=glzc 超时=30s 拓扑=single SSH=192.168.66.28:22 用户=wyeye 缓存Key=026837d0a1b6;错误链:连接建立后验证失败:10.188.101.184:1523 验证失败: Error 10004 (HY000): Parametric information is abnormal.(详细日志:C:/Users/demo/.GoNavi/Logs/gonavi.log) -> 连接建立后验证失败:10.188.101.184:1523 验证失败: Error 10004 (HY000): Parametric information is abnormal.', + '2026/06/06 19:42:37.094045 [ERROR] DBGetDatabases 获取连接失败:类型=mysql 地址=10.188.101.184:1523 数据库=(default) 用户=glzc 超时=30s 拓扑=single SSH=192.168.66.28:22 用户=wyeye;错误链:连接建立后验证失败:10.188.101.184:1523 验证失败: Error 10004 (HY000): Parametric information is abnormal.(详细日志:C:/Users/demo/.GoNavi/Logs/gonavi.log) -> 连接建立后验证失败:10.188.101.184:1523 验证失败: Error 10004 (HY000): Parametric information is abnormal.', + '2026/06/06 19:42:37.101316 [WARN] 命中数据库连接失败冷却:类型=mysql 地址=10.188.101.184:1523 数据库=(default) 用户=glzc 超时=30s 拓扑=single SSH=192.168.66.28:22 用户=wyeye 缓存Key=026837d0a1b6 剩余=29s 原因=连接建立后验证失败:10.188.101.184:1523 验证失败: Error 10004 (HY000): Parametric information is abnormal.(详细日志:C:/Users/demo/.GoNavi/Logs/gonavi.log)', + ], + }, + }, + }); + + expect(snapshot.failureEventCount).toBe(3); + expect(snapshot.primaryCategory).toBe('parameter_compatibility'); + expect(snapshot.cooldownHitCount).toBe(1); + expect(snapshot.addresses[0]?.address).toBe('10.188.101.184:1523'); + expect(snapshot.latestFailure?.cooldownSeconds).toBe(29); + expect(snapshot.nextActions.join('\n')).toContain('multiStatements'); + }); + + it('recognizes mysql compatibility fallback syntax errors as parameter or compatibility issues', () => { + const snapshot = buildRecentConnectionFailureSnapshot({ + readResult: { + success: true, + data: { + logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', + lines: [ + '2026/06/07 15:50:35.000000 [ERROR] DBGetDatabases 获取连接失败:类型=mysql 地址=127.0.0.1:48749 数据库=(default) 用户=root;错误链:连接最近失败,正在冷却中,请 29s 后重试;上次错误:连接建立后验证失败:127.0.0.1:48749 [默认兼容参数] 验证失败: Error 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near \'%2Cutf8\' at line 1;127.0.0.1:48749 [禁用 multiStatements 兼容重试] 验证失败: Error 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near \'%2Cutf8\' at line 1', + ], + }, + }, + }); + + expect(snapshot.failureEventCount).toBe(1); + expect(snapshot.primaryCategory).toBe('parameter_compatibility'); + expect(snapshot.recentFailures[0]?.rootCause).toContain('%2Cutf8'); + expect(snapshot.nextActions.join('\n')).toContain('连接参数'); + }); + + it('returns an empty-state message when the tail has no connection-related failures', () => { + const snapshot = buildRecentConnectionFailureSnapshot({ + readResult: { + success: true, + data: { + logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', + lines: [ + '2026/06/09 10:00:00.000000 [INFO] started', + '2026/06/09 10:00:01.000000 [INFO] ai ready', + ], + }, + }, + }); + + expect(snapshot.failureEventCount).toBe(0); + expect(snapshot.message).toContain('没有识别到连接失败'); + }); +}); diff --git a/frontend/src/components/ai/aiConnectionFailureInsights.ts b/frontend/src/components/ai/aiConnectionFailureInsights.ts new file mode 100644 index 0000000..17ad271 --- /dev/null +++ b/frontend/src/components/ai/aiConnectionFailureInsights.ts @@ -0,0 +1,412 @@ +const DEFAULT_CONNECTION_FAILURE_LOG_LIMIT = 120; +const MAX_CONNECTION_FAILURE_LOG_LIMIT = 240; +const MAX_RECENT_FAILURES = 8; + +type ConnectionFailureCategory = + | 'cooldown' + | 'parameter_compatibility' + | 'validation' + | 'authentication' + | 'timeout' + | 'network' + | 'ssh' + | 'startup' + | 'other'; + +type ConnectionFailureEventType = + | 'connection_failure' + | 'connection_operation_failure' + | 'cooldown_hit' + | 'ssh_failure'; + +interface ConnectionFailureEvent { + rawLine: string; + timestamp: string; + level: string; + eventType: ConnectionFailureEventType; + category: ConnectionFailureCategory; + categoryLabel: string; + connectionType: string; + address: string; + dbName: string; + sshAddress: string; + rootCause: string; + cooldownSeconds: number | null; +} + +const CONNECTION_FAILURE_CATEGORY_LABELS: Record = { + cooldown: '冷却重试', + parameter_compatibility: '连接参数/兼容性', + validation: '连接验证失败', + authentication: '认证失败', + timeout: '连接超时', + network: '网络不可达', + ssh: 'SSH 隧道失败', + startup: '驱动/进程启动失败', + other: '其他连接异常', +}; + +const normalizeLogLines = (input: unknown): string[] => + Array.isArray(input) + ? input.map((line) => String(line || '').trim()).filter(Boolean) + : []; + +const normalizeConnectionFailureLimit = (input: unknown): number => { + const value = Math.floor(Number(input) || DEFAULT_CONNECTION_FAILURE_LOG_LIMIT); + if (value < 1) { + return 1; + } + if (value > MAX_CONNECTION_FAILURE_LOG_LIMIT) { + return MAX_CONNECTION_FAILURE_LOG_LIMIT; + } + return value; +}; + +const extractLogTimestamp = (line: string): string => { + const match = String(line || '').match(/^(\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/); + return match?.[1] || ''; +}; + +const extractLogLevelAndPayload = (line: string) => { + const match = String(line || '').match(/\[(INFO|WARN|ERROR)\]\s*(.*)$/); + if (match) { + return { + level: match[1], + payload: String(match[2] || '').trim(), + }; + } + return { + level: 'OTHER', + payload: String(line || '').trim(), + }; +}; + +const extractField = (payload: string, field: string): string => { + const match = payload.match(new RegExp(`${field}=([^\\s]+)`)); + return String(match?.[1] || '').trim(); +}; + +const extractAddressCandidates = (text: string): string[] => { + const matches = text.match(/\b(?:\d{1,3}(?:\.\d{1,3}){3}|localhost|[A-Za-z0-9._-]+):\d+\b/g); + return Array.isArray(matches) ? Array.from(new Set(matches)) : []; +}; + +const sanitizeRootCause = (value: string): string => + String(value || '') + .replace(/(详细日志:[^)]+)/g, '') + .replace(/\s+/g, ' ') + .replace(/\s*->\s*/g, ' -> ') + .trim(); + +const extractRootCause = (payload: string): string => { + const errorChainIndex = payload.indexOf('错误链:'); + if (errorChainIndex >= 0) { + return sanitizeRootCause(payload.slice(errorChainIndex + '错误链:'.length)); + } + + const reasonIndex = payload.indexOf('原因='); + if (reasonIndex >= 0) { + return sanitizeRootCause(payload.slice(reasonIndex + '原因='.length)); + } + + const cooldownIndex = payload.indexOf('连接最近失败,正在冷却中'); + if (cooldownIndex >= 0) { + return sanitizeRootCause(payload.slice(cooldownIndex)); + } + + const sshFailureIndex = payload.indexOf('SSH 连接建立失败:'); + if (sshFailureIndex >= 0) { + return sanitizeRootCause(payload.slice(sshFailureIndex + 'SSH 连接建立失败:'.length)); + } + + const validationIndex = payload.indexOf('连接建立后验证失败:'); + if (validationIndex >= 0) { + return sanitizeRootCause(payload.slice(validationIndex)); + } + + return sanitizeRootCause(payload); +}; + +const extractCooldownSeconds = (payload: string, rootCause: string): number | null => { + const target = `${payload} ${rootCause}`; + const match = target.match(/(?:剩余=|请\s*)(\d+)s(?:\s*后重试)?/); + if (!match) { + return null; + } + const value = Number(match[1]); + return Number.isFinite(value) ? value : null; +}; + +const inferConnectionFailureCategory = ( + payload: string, + rootCause: string, +): ConnectionFailureCategory => { + const text = `${payload} ${rootCause}`; + const lowerText = text.toLowerCase(); + const errorText = rootCause || payload; + const lowerErrorText = errorText.toLowerCase(); + + if ( + text.includes('SSH 连接建立失败') + || /ssh\s+(dial|handshake|tunnel)/i.test(text) + || /ssh.*(认证失败|连接失败|超时)/i.test(text) + ) { + return 'ssh'; + } + if ( + lowerErrorText.includes('connect timeout') + || lowerErrorText.includes('i/o timeout') + || lowerErrorText.includes('deadline exceeded') + || errorText.includes('连接超时') + || /超时(?!\=)/.test(errorText) + ) { + return 'timeout'; + } + if ( + lowerText.includes('access denied') + || lowerText.includes('authentication failed') + || lowerText.includes('password') + || text.includes('凭据') + || text.includes('认证') + || text.includes('登录失败') + ) { + return 'authentication'; + } + if ( + lowerText.includes('parametric information is abnormal') + || lowerText.includes('error 1064') + || lowerText.includes('%2cutf8') + || lowerText.includes('multistatements') + || text.includes('兼容参数') + || lowerText.includes('syntax') + || lowerText.includes('dsn') + || text.includes('参数') + ) { + return 'parameter_compatibility'; + } + if (text.includes('验证失败')) { + return 'validation'; + } + if ( + lowerText.includes('connection refused') + || lowerText.includes('dial tcp') + || lowerText.includes('no route') + || lowerText.includes('network is unreachable') + || lowerText.includes('refused') + ) { + return 'network'; + } + if (lowerText.includes('spawn') || text.includes('启动失败') || text.includes('拉起失败')) { + return 'startup'; + } + if (text.includes('冷却')) { + return 'cooldown'; + } + return 'other'; +}; + +const detectConnectionFailureEventType = (payload: string): ConnectionFailureEventType | null => { + if (payload.includes('命中数据库连接失败冷却:') || payload.includes('连接最近失败,正在冷却中')) { + return 'cooldown_hit'; + } + if (payload.includes('SSH 连接建立失败:')) { + return 'ssh_failure'; + } + if (payload.includes('建立数据库连接失败:')) { + return 'connection_failure'; + } + if (/DB[A-Za-z0-9_]+\s+获取连接失败:/.test(payload)) { + return 'connection_operation_failure'; + } + return null; +}; + +const buildConnectionFailureEvent = (line: string): ConnectionFailureEvent | null => { + const timestamp = extractLogTimestamp(line); + const { level, payload } = extractLogLevelAndPayload(line); + const eventType = detectConnectionFailureEventType(payload); + if (!eventType) { + return null; + } + + const rootCause = extractRootCause(payload); + const extractedType = extractField(payload, '类型'); + const extractedAddress = extractField(payload, '地址'); + const addressCandidates = extractAddressCandidates(rootCause); + const address = extractedAddress || addressCandidates[0] || ''; + const category = inferConnectionFailureCategory(payload, rootCause); + + return { + rawLine: line, + timestamp, + level, + eventType, + category, + categoryLabel: CONNECTION_FAILURE_CATEGORY_LABELS[category], + connectionType: extractedType, + address, + dbName: extractField(payload, '数据库'), + sshAddress: extractField(payload, 'SSH'), + rootCause, + cooldownSeconds: extractCooldownSeconds(payload, rootCause), + }; +}; + +const appendUnique = (items: string[], value: string) => { + const normalized = String(value || '').trim(); + if (!normalized || items.includes(normalized)) { + return; + } + items.push(normalized); +}; + +const buildNextActions = (events: ConnectionFailureEvent[]): string[] => { + const categories = new Set(events.map((event) => event.category)); + const hasCooldownEvent = events.some((event) => event.eventType === 'cooldown_hit'); + const nextActions: string[] = []; + const latestEvent = events[events.length - 1]; + + if (hasCooldownEvent || categories.has('cooldown')) { + appendUnique(nextActions, '先修复上一次真实连接错误,再重试;只反复刷新会持续命中连接冷却。'); + } + if (categories.has('parameter_compatibility')) { + appendUnique(nextActions, '优先核对连接参数、DSN 和协议兼容性,尤其是 multiStatements、charset、额外 query 参数和 URL 编码。'); + } + if (categories.has('validation')) { + appendUnique(nextActions, '检查服务端返回的验证失败细节,确认当前数据库类型、驱动协议、库名或 Service Name 与目标服务匹配。'); + } + if (categories.has('authentication')) { + appendUnique(nextActions, '核对用户名、密码、认证库、租户或 Service Name 是否正确,确认服务端是否允许当前账号登录。'); + } + if (categories.has('ssh')) { + appendUnique(nextActions, '核对 SSH 跳板机地址、端口、账号和隧道目标地址,必要时先验证跳板机到数据库的连通性。'); + } + if (categories.has('timeout') || categories.has('network')) { + appendUnique(nextActions, '检查目标地址、端口、防火墙、代理和隧道链路是否可达,确认服务端当前确实在监听。'); + } + if (categories.has('startup')) { + appendUnique(nextActions, '检查驱动进程或外部依赖是否能正常启动,必要时先在本机或目标主机单独验证启动命令。'); + } + if (latestEvent?.address) { + appendUnique(nextActions, `如需核对当前界面里的连接是否还是同一目标,可再调用 inspect_current_connection 检查是否仍指向 ${latestEvent.address}。`); + } + if (nextActions.length === 0) { + appendUnique(nextActions, '先结合 inspect_current_connection 和 inspect_saved_connections 核对当前连接配置,再扩大日志窗口继续排查。'); + } + return nextActions; +}; + +export const buildRecentConnectionFailureSnapshot = (params: { + readResult?: any; + keyword?: unknown; + lineLimit?: unknown; +}) => { + const data = params.readResult?.data && typeof params.readResult.data === 'object' + ? params.readResult.data as Record + : {}; + const lines = normalizeLogLines(data.lines); + const keyword = String(data.keyword || params.keyword || '').trim(); + const requestedLineLimit = normalizeConnectionFailureLimit(data.requestedLineLimit ?? params.lineLimit); + const events = lines + .map((line) => buildConnectionFailureEvent(line)) + .filter((event): event is ConnectionFailureEvent => Boolean(event)); + const latestEvent = events[events.length - 1]; + + const categoryCounts = new Map(); + const addressCounts = new Map; lastSeenAt: string }>(); + events.forEach((event) => { + categoryCounts.set(event.category, (categoryCounts.get(event.category) || 0) + 1); + if (!event.address) { + return; + } + const current = addressCounts.get(event.address) || { + count: 0, + connectionTypes: new Set(), + lastSeenAt: '', + }; + current.count += 1; + if (event.connectionType) { + current.connectionTypes.add(event.connectionType); + } + current.lastSeenAt = event.timestamp || current.lastSeenAt; + addressCounts.set(event.address, current); + }); + + const categorySummary = Array.from(categoryCounts.entries()) + .sort((left, right) => right[1] - left[1]) + .map(([category, count]) => ({ + category, + label: CONNECTION_FAILURE_CATEGORY_LABELS[category], + count, + })); + const primaryCategory = categorySummary[0]?.category || ''; + + const addresses = Array.from(addressCounts.entries()) + .sort((left, right) => right[1].count - left[1].count) + .map(([address, summary]) => ({ + address, + count: summary.count, + connectionTypes: Array.from(summary.connectionTypes), + lastSeenAt: summary.lastSeenAt, + })); + + return { + logPath: String(data.logPath || ''), + keyword, + requestedLineLimit, + returnedLineCount: lines.length, + fileWindowTruncated: data.fileWindowTruncated === true, + matchedLinesTruncated: data.matchedLinesTruncated === true, + failureEventCount: events.length, + hasRecentFailures: events.length > 0, + primaryCategory, + primaryCategoryLabel: primaryCategory + ? CONNECTION_FAILURE_CATEGORY_LABELS[primaryCategory as ConnectionFailureCategory] + : '', + cooldownHitCount: events.filter((event) => event.eventType === 'cooldown_hit').length, + validationFailureCount: events.filter((event) => event.category === 'validation').length, + sshFailureCount: events.filter((event) => event.category === 'ssh').length, + categorySummary, + addresses, + latestFailureAt: latestEvent?.timestamp || '', + latestFailure: latestEvent + ? { + timestamp: latestEvent.timestamp, + level: latestEvent.level, + eventType: latestEvent.eventType, + category: latestEvent.category, + categoryLabel: latestEvent.categoryLabel, + connectionType: latestEvent.connectionType, + address: latestEvent.address, + dbName: latestEvent.dbName, + sshAddress: latestEvent.sshAddress, + cooldownSeconds: latestEvent.cooldownSeconds, + rootCause: latestEvent.rootCause, + rawLine: latestEvent.rawLine, + } + : null, + recentFailures: events + .slice(-MAX_RECENT_FAILURES) + .reverse() + .map((event) => ({ + timestamp: event.timestamp, + level: event.level, + eventType: event.eventType, + category: event.category, + categoryLabel: event.categoryLabel, + connectionType: event.connectionType, + address: event.address, + dbName: event.dbName, + sshAddress: event.sshAddress, + cooldownSeconds: event.cooldownSeconds, + rootCause: event.rootCause, + rawLine: event.rawLine, + })), + nextActions: buildNextActions(events), + message: events.length > 0 + ? `最近日志里识别到 ${events.length} 条连接相关异常,最新一条是 ${latestEvent?.categoryLabel || '连接异常'}` + : keyword + ? `最近日志里没有找到与“${keyword}”相关的连接失败记录` + : '最近日志里没有识别到连接失败、验证失败或连接冷却记录', + }; +}; diff --git a/frontend/src/components/ai/aiLocalToolExecutor.connectionFailureInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.connectionFailureInspection.test.ts new file mode 100644 index 0000000..36f7316 --- /dev/null +++ b/frontend/src/components/ai/aiLocalToolExecutor.connectionFailureInspection.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AIToolCall } from '../../types'; +import { executeLocalAIToolCall } from './aiLocalToolExecutor'; + +const buildToolCall = ( + name: string, + args: Record, +): AIToolCall => ({ + id: `call-${name}`, + type: 'function', + function: { + name, + arguments: JSON.stringify(args), + }, +}); + +describe('aiLocalToolExecutor inspect_recent_connection_failures', () => { + it('returns a structured snapshot for recent connection failures, cooldown hits, and compatibility errors', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_recent_connection_failures', { + keyword: 'mysql', + lineLimit: 120, + }), + connections: [], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + readAppLogTail: vi.fn().mockResolvedValue({ + success: true, + data: { + logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', + keyword: 'mysql', + requestedLineLimit: 120, + lines: [ + '2026/06/07 15:50:35.000000 [ERROR] 建立数据库连接失败:类型=mysql 地址=127.0.0.1:48749 数据库=(default) 用户=root;错误链:连接建立后验证失败:127.0.0.1:48749 [默认兼容参数] 验证失败: Error 1064 (42000): You have an error in your SQL syntax near \'%2Cutf8\' at line 1', + '2026/06/07 15:50:36.000000 [WARN] 命中数据库连接失败冷却:类型=mysql 地址=127.0.0.1:48749 数据库=(default) 用户=root 剩余=29s 原因=连接建立后验证失败:127.0.0.1:48749 [禁用 multiStatements 兼容重试] 验证失败: Error 1064 (42000): You have an error in your SQL syntax near \'%2Cutf8\' at line 1', + ], + }, + }), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"primaryCategory":"parameter_compatibility"'); + expect(result.content).toContain('"cooldownHitCount":1'); + expect(result.content).toContain('127.0.0.1:48749'); + expect(result.content).toContain('multiStatements'); + }); +}); diff --git a/frontend/src/components/ai/aiSlashCommands.test.ts b/frontend/src/components/ai/aiSlashCommands.test.ts index d8b8738..57d9cd0 100644 --- a/frontend/src/components/ai/aiSlashCommands.test.ts +++ b/frontend/src/components/ai/aiSlashCommands.test.ts @@ -14,6 +14,7 @@ describe('aiSlashCommands', () => { expect(commands.some((command) => command.cmd === '/health')).toBe(true); expect(commands.some((command) => command.cmd === '/mcp')).toBe(true); expect(commands.some((command) => command.cmd === '/mcpadd')).toBe(true); + expect(commands.some((command) => command.cmd === '/connfail')).toBe(true); expect(commands.some((command) => command.cmd === '/shortcuts')).toBe(true); expect(commands.some((command) => command.cmd === '/applog')).toBe(true); expect(commands.some((command) => command.cmd === '/airender')).toBe(true); @@ -31,6 +32,11 @@ describe('aiSlashCommands', () => { expect(filterAISlashCommands('/sho').map((command) => command.cmd)).toContain('/shortcuts'); }); + it('supports filtering connection-failure diagnostics by chinese keyword and command prefix', () => { + expect(filterAISlashCommands('连接失败').map((command) => command.cmd)).toContain('/connfail'); + expect(filterAISlashCommands('/conn').map((command) => command.cmd)).toContain('/connfail'); + }); + it('supports filtering app-log diagnostics by chinese keyword and command prefix', () => { expect(filterAISlashCommands('日志').map((command) => command.cmd)).toContain('/applog'); expect(filterAISlashCommands('/app').map((command) => command.cmd)).toContain('/applog'); @@ -56,6 +62,7 @@ describe('aiSlashCommands', () => { expect(featured).toContain('/health'); expect(featured).toContain('/mcp'); expect(featured).toContain('/mcpadd'); + expect(featured).toContain('/connfail'); expect(featured).not.toContain('/shortcuts'); }); }); diff --git a/frontend/src/components/ai/aiSlashCommands.ts b/frontend/src/components/ai/aiSlashCommands.ts index 44fce4c..21a4e2b 100644 --- a/frontend/src/components/ai/aiSlashCommands.ts +++ b/frontend/src/components/ai/aiSlashCommands.ts @@ -50,6 +50,7 @@ export const DEFAULT_AI_SLASH_COMMANDS: AISlashCommandDefinition[] = [ { cmd: '/health', label: '🩺 AI 配置体检', desc: '调用体检探针总览当前 AI 配置', prompt: '请先调用 inspect_ai_setup_health,对当前 GoNavi AI 配置做一次完整体检,然后总结 blockers、warnings 和 nextActions。', category: 'diagnose', featured: true, keywords: ['health', '体检', 'ai配置', '探针'] }, { cmd: '/mcp', label: '🪛 排查 MCP 接入', desc: '检查 MCP 服务和外部客户端状态', prompt: '请先调用 inspect_mcp_setup,帮我盘点当前 MCP 服务、工具发现结果,以及 Claude Code / Codex 的接入状态。', category: 'diagnose', featured: true, keywords: ['mcp', 'codex', 'claude', '外部客户端'] }, { cmd: '/mcpadd', label: '🧭 新增 MCP 指引', desc: '查看 command、args、env 和模板怎么填', prompt: '请先调用 inspect_mcp_authoring_guide,再结合 inspect_mcp_setup,告诉我新增 GoNavi MCP 服务时 command、args、env、timeout 应该怎么填,以及最接近的模板应该选哪个。', category: 'diagnose', featured: true, keywords: ['mcp新增', 'command', 'args', 'env', '模板'] }, + { cmd: '/connfail', label: '🧯 连接失败探针', desc: '总结最近连接失败、冷却和验证异常', prompt: '请先调用 inspect_recent_connection_failures,帮我总结最近数据库连接失败、连接冷却、验证失败和 SSH 隧道异常的真实日志结论;如果已经有明确地址或类型,再结合 inspect_current_connection 或 inspect_saved_connections 继续缩小范围。', category: 'diagnose', featured: true, keywords: ['连接失败', '冷却', '验证失败', 'ssh', 'mysql'] }, { cmd: '/shortcuts', label: '⌨️ 快捷键探针', desc: '读取当前 Win/Mac 快捷键配置', prompt: '请先调用 inspect_shortcuts,告诉我当前 GoNavi 的快捷键配置,尤其是执行 SQL、切换结果区、打开 AI 面板和 AI 发送消息这些动作在当前平台和另一平台分别怎么按,是否改过默认值。', category: 'diagnose', keywords: ['快捷键', 'shortcuts', '结果区', 'mac', 'windows'] }, { cmd: '/applog', label: '🪵 应用日志', desc: '回看最近 GoNavi 应用日志', prompt: '请先调用 inspect_app_logs,帮我看最近 GoNavi 应用日志里的错误和警告;如果我提到连接失败、MCP 拉起失败、启动异常或 gonavi.log,就优先结合关键词继续筛。', category: 'diagnose', keywords: ['日志', 'gonavi.log', 'mcp报错', '连接失败', '启动异常'] }, { cmd: '/airender', label: '🧯 AI 渲染异常', desc: '读取最近一次 AI 消息渲染失败记录', prompt: '请先调用 inspect_ai_last_render_error,告诉我最近一次 AI 消息渲染失败记录里是哪条消息、报错摘要是什么,以及下一步该怎么排查。', category: 'diagnose', keywords: ['渲染失败', '气泡空白', 'ai消息', 'render', '白块'] }, diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index b1f7817..eb5d63a 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -34,6 +34,7 @@ import { } from './aiWorkspaceInsights'; import { buildShortcutSnapshot } from './aiShortcutInsights'; import { buildAILastRenderErrorSnapshot } from './aiLastRenderErrorInsights'; +import { buildRecentConnectionFailureSnapshot } from './aiConnectionFailureInsights'; import { executeAIConfigSnapshotToolCall } from './aiSnapshotInspectionAIConfigToolExecutor'; import type { AISnapshotInspectionRuntime, @@ -269,6 +270,25 @@ export async function executeSnapshotInspectionToolCall( success: true, }; } + case 'inspect_recent_connection_failures': { + const readResult = typeof runtime?.readAppLogTail === 'function' + ? await runtime.readAppLogTail(Number(args.lineLimit) || 120, String(args.keyword || '')) + : { success: false, message: '当前环境暂不支持读取 GoNavi 应用日志' }; + if (!readResult?.success) { + return { + content: `读取最近连接失败记录失败: ${readResult?.message || '未知错误'}`, + success: false, + }; + } + return { + content: JSON.stringify(buildRecentConnectionFailureSnapshot({ + readResult, + keyword: args.keyword, + lineLimit: args.lineLimit, + })), + success: true, + }; + } case 'inspect_ai_last_render_error': return { content: JSON.stringify(buildAILastRenderErrorSnapshot()), @@ -335,6 +355,7 @@ export async function executeSnapshotInspectionToolCall( inspect_recent_sql_logs: '获取最近 SQL 日志失败', inspect_recent_sql_activity: '汇总最近 SQL 活动失败', inspect_app_logs: '读取 GoNavi 应用日志失败', + inspect_recent_connection_failures: '汇总最近连接失败记录失败', inspect_ai_last_render_error: '读取最近一次 AI 渲染异常失败', inspect_saved_queries: '读取已保存查询失败', inspect_sql_snippets: '读取 SQL 片段失败', diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index 6f06ecd..5f921e1 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_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], + availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], skills, userPromptSettings, }); @@ -90,6 +90,7 @@ describe('buildAISystemContextMessages', () => { expect(joined).toContain('inspect_external_sql_directories'); expect(joined).toContain('inspect_external_sql_file'); expect(joined).toContain('inspect_recent_sql_activity'); + expect(joined).toContain('inspect_recent_connection_failures 读取真实连接失败总结'); expect(joined).toContain('inspect_app_logs 读取真实应用日志尾部'); expect(joined).toContain('inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录'); expect(joined).toContain('inspect_saved_queries'); diff --git a/frontend/src/components/ai/aiSystemContextMessages.ts b/frontend/src/components/ai/aiSystemContextMessages.ts index 0056641..49d5e47 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.ts @@ -225,6 +225,19 @@ const appendAppLogInspectionGuidance = ( }); }; +const appendConnectionFailureInspectionGuidance = ( + messages: AISystemContextMessage[], + availableToolNames: string[], +) => { + if (!availableToolNames.includes('inspect_recent_connection_failures')) { + return; + } + messages.push({ + role: 'system', + content: '如果用户提到“为什么连接不上”“连接最近失败,正在冷却中”“验证失败”“SSH 隧道是不是有问题”“multiStatements / 参数兼容异常”,优先调用 inspect_recent_connection_failures 读取真实连接失败总结,再决定是否继续下钻 inspect_current_connection、inspect_saved_connections 或 inspect_app_logs。', + }); +}; + const appendAILastRenderErrorInspectionGuidance = ( messages: AISystemContextMessage[], availableToolNames: string[], @@ -478,6 +491,7 @@ SELECT * FROM users WHERE status = 1; appendMCPAuthoringInspectionGuidance(systemMessages, availableToolNames); appendAIGuidanceInspectionGuidance(systemMessages, availableToolNames); appendShortcutInspectionGuidance(systemMessages, availableToolNames); + appendConnectionFailureInspectionGuidance(systemMessages, availableToolNames); appendAppLogInspectionGuidance(systemMessages, availableToolNames); appendAILastRenderErrorInspectionGuidance(systemMessages, availableToolNames); if (availableToolNames.includes('inspect_current_connection')) { diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index 111bc77..5999111 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -52,6 +52,7 @@ const TOOL_ACTION_LABELS: Record = { inspect_recent_sql_logs: '回看最近 SQL 执行日志', inspect_recent_sql_activity: '总结最近 SQL 活动', inspect_app_logs: '回看 GoNavi 应用日志', + inspect_recent_connection_failures: '总结最近连接失败记录', inspect_ai_last_render_error: '读取最近一次 AI 渲染异常', inspect_saved_queries: '检索本地已保存查询', inspect_sql_snippets: '读取 SQL 片段模板', diff --git a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts index 2d41f02..49ed779 100644 --- a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts @@ -401,6 +401,29 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_recent_connection_failures", + icon: "🧯", + desc: "总结最近数据库连接失败与冷却原因", + detail: + "从最近一段 gonavi.log 里提取数据库连接失败、连接验证失败、SSH 隧道异常和连接冷却命中记录,自动归类主要问题类型、最新地址、最新根因和下一步建议。适合用户提到“为什么连接不上”“连接最近失败正在冷却”“验证失败”“SSH 隧道是不是有问题”时,先读这份结构化总结,而不是人工翻整段日志。", + params: "keyword?, lineLimit?(默认 120)", + tool: { + type: "function", + function: { + name: "inspect_recent_connection_failures", + description: + "汇总最近 GoNavi 应用日志中的数据库连接失败、连接验证失败、SSH 隧道失败和冷却命中记录,并返回主要异常类别、最新地址、最新根因与建议动作。适用于用户提到为什么连接不上、最近一直命中连接冷却、服务端验证失败、multiStatements 或参数兼容异常时,优先读取这份结构化连接失败总结,不要直接让模型肉眼翻整段日志。", + parameters: { + type: "object", + properties: { + keyword: { type: "string", description: "可选,按连接类型、地址或异常关键词过滤,例如 mysql、ssh、timeout、127.0.0.1" }, + lineLimit: { type: "number", description: "可选,最多分析多少行日志,默认 120,最大 240" }, + }, + }, + }, + }, + }, { name: "inspect_ai_last_render_error", icon: "🧯", diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index 933ebf4..d544fc2 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -108,6 +108,13 @@ describe('aiToolRegistry', () => { expect(info?.tool.function.description).toContain('gonavi.log'); }); + it('registers the recent-connection-failure inspector as a builtin tool', () => { + const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_connection_failures'); + expect(info).toBeTruthy(); + expect(info?.desc).toContain('连接失败'); + expect(info?.tool.function.description).toContain('multiStatements'); + }); + it('registers the ai-render-error inspector as a builtin tool', () => { const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_last_render_error'); expect(info).toBeTruthy(); @@ -118,6 +125,7 @@ describe('aiToolRegistry', () => { it('registers the recent-sql-activity, saved-query, and sql-snippet inspectors as builtin tools', () => { const recentActivityTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_sql_activity'); const appLogTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_app_logs'); + const connectionFailureTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_connection_failures'); const renderErrorTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_last_render_error'); const savedQueryTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_saved_queries'); const aiSessionsTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_sessions'); @@ -127,6 +135,8 @@ describe('aiToolRegistry', () => { expect(recentActivityTool?.tool.function.description).toContain('最近 SQL 活动'); expect(appLogTool?.desc).toContain('GoNavi 应用日志'); expect(appLogTool?.tool.function.description).toContain('应用日志'); + expect(connectionFailureTool?.desc).toContain('连接失败'); + expect(connectionFailureTool?.tool.function.description).toContain('连接冷却'); expect(renderErrorTool?.desc).toContain('渲染异常记录'); expect(renderErrorTool?.tool.function.description).toContain('气泡局部报错'); expect(savedQueryTool?.desc).toContain('已保存的 SQL 查询'); @@ -168,6 +178,7 @@ describe('aiToolRegistry', () => { 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); expect(tools.some((item) => item.function.name === 'inspect_app_logs')).toBe(true); + expect(tools.some((item) => item.function.name === 'inspect_recent_connection_failures')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_last_render_error')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_saved_queries')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_sessions')).toBe(true);