From 4265d7cfa9bb1d60f474b3a71f292f3b7de898e7 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 11 Jun 2026 12:00:17 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E8=AF=B7=E6=B1=82=E6=97=A5=E5=BF=97=E8=87=AA?= =?UTF-8?q?=E6=9F=A5=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/AIBuiltinToolsCatalog.test.tsx | 3 + ...olExecutor.aiUpstreamLogInspection.test.ts | 92 +++++++ .../ai/aiSnapshotInspectionToolExecutor.ts | 28 ++ .../ai/aiSystemContextMessages.test.ts | 3 +- .../ai/aiSystemInspectionGuidance.ts | 6 + .../components/ai/aiToolCatalogInsights.ts | 37 +++ .../components/ai/aiUpstreamLogInsights.ts | 249 ++++++++++++++++++ .../src/utils/aiBuiltinInspectionToolInfo.ts | 29 ++ frontend/src/utils/aiBuiltinToolCatalog.ts | 5 + frontend/src/utils/aiComposerNotice.test.ts | 12 + frontend/src/utils/aiToolRegistry.test.ts | 9 + 11 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts create mode 100644 frontend/src/components/ai/aiUpstreamLogInsights.ts diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index 7358842..de27673 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -44,6 +44,9 @@ describe('AIBuiltinToolsCatalog', () => { expect(markup).toContain('inspect_ai_providers'); expect(markup).toContain('排查聊天发送状态'); expect(markup).toContain('inspect_ai_chat_readiness'); + expect(markup).toContain('追踪 AI 上游请求'); + expect(markup).toContain('inspect_ai_upstream_logs'); + expect(markup).toContain('请求体预览'); expect(markup).toContain('排查 MCP 接入状态'); expect(markup).toContain('inspect_mcp_setup'); expect(markup).toContain('新增 MCP 填写指引'); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts new file mode 100644 index 0000000..e4eeb37 --- /dev/null +++ b/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts @@ -0,0 +1,92 @@ +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_ai_upstream_logs', () => { + it('returns sanitized upstream request payloads and request lifecycle summaries from gonavi.log', async () => { + const readAppLogTail = vi.fn().mockResolvedValue({ + success: true, + data: { + logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', + keyword: 'openai', + requestedLineLimit: 160, + fileWindowTruncated: false, + matchedLinesTruncated: false, + lines: [ + '2026/06/11 11:20:00.000000 [INFO] AI 上游请求开始:requestId=openai-123 provider=openai method=POST endpoint=https://api.example.com/v1/chat/completions?key=[REDACTED] body={"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"api_key":"[REDACTED]"}', + '2026/06/11 11:20:01.000000 [INFO] AI 上游请求完成:requestId=openai-123 provider=openai endpoint=https://api.example.com/v1/chat/completions?key=[REDACTED] status=200 duration=981ms', + '2026/06/11 11:20:02.000000 [WARN] AI 上游请求失败:requestId=gemini-456 provider=gemini endpoint=https://generativelanguage.googleapis.com duration=2s err=upstream Authorization Bearer abcdefghijklmnopqrstuvwxyz failed', + ], + }, + }); + + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_upstream_logs', { + provider: 'openai', + includeBody: true, + }), + connections: [], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + readAppLogTail, + }, + }); + + expect(result.success).toBe(true); + expect(readAppLogTail).toHaveBeenCalledWith(160, 'openai'); + expect(result.content).toContain('"logPath":"C:/Users/demo/.GoNavi/Logs/gonavi.log"'); + expect(result.content).toContain('"upstreamEventCount":2'); + expect(result.content).toContain('"requestId":"openai-123"'); + expect(result.content).toContain('"provider":"openai"'); + expect(result.content).toContain('"state":"completed"'); + expect(result.content).toContain('"status":200'); + expect(result.content).toContain('"bodyPreview"'); + expect(result.content).toContain('gpt-5.5'); + expect(result.content).not.toContain('abcdefghijklmnopqrstuvwxyz'); + }); + + it('returns an actionable empty-state message when no upstream request log is available', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_upstream_logs', { + requestId: 'openai-missing', + }), + 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: 'openai-missing', + requestedLineLimit: 160, + lines: [], + }, + }), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"upstreamEventCount":0'); + expect(result.content).toContain('请先发送一次 AI 消息'); + expect(result.content).toContain('扩大 lineLimit'); + }); +}); diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index d2b7a83..ca98ef9 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -27,6 +27,7 @@ import { buildSavedConnectionsSnapshot } from './aiSavedConnectionInsights'; import { buildExternalSQLFileSnapshot } from './aiExternalSqlFileInsights'; import { buildExternalSQLDirectoriesSnapshot } from './aiExternalSqlInsights'; import { buildAppLogSnapshot } from './aiAppLogInsights'; +import { buildAIUpstreamLogSnapshot } from './aiUpstreamLogInsights'; import { findBestMatchingExternalSQLDirectory } from './aiExternalSqlPathUtils'; import { buildRecentSqlActivitySnapshot, @@ -356,6 +357,32 @@ export async function executeSnapshotInspectionToolCall( success: true, }; } + case 'inspect_ai_upstream_logs': { + const keyword = String(args.requestId || args.provider || args.keyword || 'AI 上游请求').trim(); + const readResult = typeof runtime?.readAppLogTail === 'function' + ? await runtime.readAppLogTail(Number(args.lineLimit) || 160, keyword) + : { success: false, message: '当前环境暂不支持读取 GoNavi 应用日志' }; + if (!readResult?.success) { + return { + content: `读取 AI 上游请求日志失败: ${readResult?.message || '未知错误'}`, + success: false, + }; + } + return { + content: JSON.stringify(buildAIUpstreamLogSnapshot({ + readResult, + provider: args.provider, + requestId: args.requestId, + keyword: args.keyword, + lineLimit: args.lineLimit, + requestLimit: args.requestLimit, + includeBody: args.includeBody !== false, + includeLines: args.includeLines === true, + bodyPreviewLimit: args.bodyPreviewLimit, + })), + success: true, + }; + } case 'inspect_recent_connection_failures': { const readResult = typeof runtime?.readAppLogTail === 'function' ? await runtime.readAppLogTail(Number(args.lineLimit) || 120, String(args.keyword || '')) @@ -443,6 +470,7 @@ export async function executeSnapshotInspectionToolCall( inspect_sql_editor_transaction: '读取 SQL 编辑器事务状态失败', inspect_sql_risk: '检查 SQL 风险失败', inspect_app_logs: '读取 GoNavi 应用日志失败', + inspect_ai_upstream_logs: '读取 AI 上游请求日志失败', inspect_recent_connection_failures: '汇总最近连接失败记录失败', inspect_ai_last_render_error: '读取最近一次 AI 渲染异常失败', inspect_ai_message_flow: '读取 AI 消息流诊断失败', diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index 134e9ae..c7a4f67 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_app_health', 'inspect_ai_support_bundle', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_ai_tool_catalog', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_mcp_draft', 'inspect_mcp_tool_schema', '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_sql_editor_transaction', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_ai_message_flow', 'inspect_ai_context_budget', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], + availableToolNames: ['inspect_workspace_tabs', 'inspect_app_health', 'inspect_ai_support_bundle', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_ai_upstream_logs', 'inspect_ai_tool_catalog', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_mcp_draft', 'inspect_mcp_tool_schema', '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_sql_editor_transaction', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_ai_message_flow', 'inspect_ai_context_budget', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], skills, userPromptSettings, }); @@ -82,6 +82,7 @@ describe('buildAISystemContextMessages', () => { expect(joined).toContain('inspect_ai_safety 读取真实安全边界'); expect(joined).toContain('inspect_ai_providers 读取真实供应商配置'); expect(joined).toContain('inspect_ai_chat_readiness 读取真实发送前置状态'); + expect(joined).toContain('inspect_ai_upstream_logs 读取脱敏后的真实请求日志'); expect(joined).toContain('inspect_ai_tool_catalog 按关键词读取真实工具目录'); expect(joined).toContain('inspect_mcp_setup 读取真实 MCP 配置'); expect(joined).toContain('inspect_mcp_authoring_guide 读取真实新增指引和模板'); diff --git a/frontend/src/components/ai/aiSystemInspectionGuidance.ts b/frontend/src/components/ai/aiSystemInspectionGuidance.ts index 9274a7f..0f762ef 100644 --- a/frontend/src/components/ai/aiSystemInspectionGuidance.ts +++ b/frontend/src/components/ai/aiSystemInspectionGuidance.ts @@ -86,6 +86,12 @@ export const appendDatabaseInspectionGuidanceMessages = ( 'inspect_ai_chat_readiness', '如果用户提到“为什么现在不能发送”“当前 AI 聊天到底缺什么配置”“输入区准备好了没有”,优先调用 inspect_ai_chat_readiness 读取真实发送前置状态,不要只凭界面现象或记忆判断。', ); + appendGuidanceIfToolAvailable( + messages, + availableToolNames, + 'inspect_ai_upstream_logs', + '如果用户提到“AI 上游请求”“请求入参/请求体”“requestId”“发给模型的 payload”“上游接口报错具体传了什么”,优先调用 inspect_ai_upstream_logs 读取脱敏后的真实请求日志,再结合 inspect_ai_providers 或 inspect_ai_message_flow 继续定位。', + ); appendGuidanceIfToolAvailable( messages, availableToolNames, diff --git a/frontend/src/components/ai/aiToolCatalogInsights.ts b/frontend/src/components/ai/aiToolCatalogInsights.ts index 536b3eb..c1d60ce 100644 --- a/frontend/src/components/ai/aiToolCatalogInsights.ts +++ b/frontend/src/components/ai/aiToolCatalogInsights.ts @@ -17,6 +17,13 @@ const normalizeLimit = (value: unknown): number => const matchesAnyText = (keyword: string, values: unknown[]): boolean => !keyword || values.some((value) => normalizeText(value).includes(keyword)); +const scoreKeywordMatch = (keyword: string, values: Array<{ value: unknown; weight: number }>): number => + !keyword + ? 0 + : values.reduce((score, item) => ( + normalizeText(item.value).includes(keyword) ? score + item.weight : score + ), 0); + const readMCPToolParameterSummary = (tool: AIMCPToolDescriptor) => { const schema = tool.inputSchema && typeof tool.inputSchema === 'object' ? tool.inputSchema as Record @@ -57,6 +64,17 @@ export const buildAIToolCatalogSnapshot = (params: { const matchedFlows = BUILTIN_TOOL_FLOWS .filter((flow) => matchesAnyText(keyword, [flow.title, flow.steps, flow.description])) + .map((flow, index) => ({ + flow, + index, + score: scoreKeywordMatch(keyword, [ + { value: flow.title, weight: 100 }, + { value: flow.steps, weight: 60 }, + { value: flow.description, weight: 20 }, + ]), + })) + .sort((left, right) => (right.score - left.score) || (left.index - right.index)) + .map((item) => item.flow) .slice(0, limit); const matchedBuiltinTools = builtinTools @@ -76,6 +94,25 @@ export const buildAIToolCatalogSnapshot = (params: { ]), ]); }) + .map((tool, index) => ({ + tool, + index, + score: normalizedToolName + ? 0 + : scoreKeywordMatch(keyword, [ + { value: tool.name, weight: 120 }, + { value: tool.desc, weight: 100 }, + { value: tool.params, weight: 60 }, + { value: tool.detail, weight: 20 }, + ...describeBuiltinToolParameters(tool).flatMap((param) => [ + { value: param.name, weight: 40 }, + { value: param.description, weight: 20 }, + { value: param.enumValues.join(' '), weight: 20 }, + ]), + ]), + })) + .sort((left, right) => (right.score - left.score) || (left.index - right.index)) + .map((item) => item.tool) .slice(0, limit) .map((tool) => ({ name: tool.name, diff --git a/frontend/src/components/ai/aiUpstreamLogInsights.ts b/frontend/src/components/ai/aiUpstreamLogInsights.ts new file mode 100644 index 0000000..f1ef50d --- /dev/null +++ b/frontend/src/components/ai/aiUpstreamLogInsights.ts @@ -0,0 +1,249 @@ +const DEFAULT_AI_UPSTREAM_LOG_LIMIT = 160; +const MAX_AI_UPSTREAM_LOG_LIMIT = 300; +const DEFAULT_AI_UPSTREAM_REQUEST_LIMIT = 12; +const MAX_AI_UPSTREAM_REQUEST_LIMIT = 40; +const DEFAULT_AI_UPSTREAM_BODY_LIMIT = 6000; +const MAX_AI_UPSTREAM_BODY_LIMIT = 12000; + +const secretLikeValuePatterns = [ + /bearer\s+[a-z0-9._~+/=-]{8,}/gi, + /\bsk-[a-z0-9._-]{8,}/gi, + /\bgh[pousr]_[a-z0-9_]{8,}/gi, + /\bxox[baprs]-[a-z0-9-]{8,}/gi, +]; + +type AIUpstreamLogEventType = 'started' | 'completed' | 'failed' | 'other'; + +interface AIUpstreamLogEvent { + type: AIUpstreamLogEventType; + requestId: string; + provider: string; + method: string; + endpoint: string; + status?: number; + duration?: string; + bodyPreview?: string; + error?: string; + line: string; +} + +interface AIUpstreamRequestSummary { + requestId: string; + provider: string; + method: string; + endpoint: string; + state: 'started' | 'completed' | 'failed' | 'unknown'; + status?: number; + duration?: string; + bodyPreview?: string; + error?: string; + eventCount: number; + hasBody: boolean; +} + +const clampNumber = (value: unknown, fallback: number, min: number, max: number): number => { + const normalized = Math.floor(Number(value) || fallback); + if (normalized < min) return min; + if (normalized > max) return max; + return normalized; +}; + +const normalizeLogLines = (input: unknown): string[] => + Array.isArray(input) + ? input.map((line) => String(line || '').trim()).filter(Boolean) + : []; + +const redactAIUpstreamLogPreview = (value: string): string => { + let result = value; + secretLikeValuePatterns.forEach((pattern) => { + result = result.replace(pattern, '[REDACTED]'); + }); + return result.replace(/data:([^;,]+);base64,[a-z0-9+/=._-]+/gi, 'data:$1;base64,[REDACTED]'); +}; + +const truncateText = (value: string, limit: number): string => { + if (value.length <= limit) { + return value; + } + return `${value.slice(0, limit)}...[truncated ${value.length - limit} chars]`; +}; + +const extractField = (line: string, field: string): string => { + const match = line.match(new RegExp(`(?:^|[\\s:])${field}=([^\\s]+)`)); + return match?.[1] || ''; +}; + +const extractTailField = (line: string, field: string): string => { + const marker = ` ${field}=`; + const index = line.indexOf(marker); + if (index < 0) { + return ''; + } + return line.slice(index + marker.length).trim(); +}; + +const resolveEventType = (line: string): AIUpstreamLogEventType => { + if (line.includes('AI 上游请求开始')) return 'started'; + if (line.includes('AI 上游请求完成')) return 'completed'; + if (line.includes('AI 上游请求失败')) return 'failed'; + return 'other'; +}; + +const parseAIUpstreamLogEvent = ( + line: string, + bodyPreviewLimit: number, +): AIUpstreamLogEvent | null => { + if (!line.includes('AI 上游请求')) { + return null; + } + + const type = resolveEventType(line); + const requestId = extractField(line, 'requestId'); + const provider = extractField(line, 'provider'); + const method = extractField(line, 'method'); + const endpoint = extractField(line, 'endpoint'); + const statusText = extractField(line, 'status'); + const bodyText = extractTailField(line, 'body'); + const errorText = extractTailField(line, 'err'); + const duration = extractField(line, 'duration'); + + return { + type, + requestId, + provider, + method, + endpoint, + status: statusText ? Number(statusText) : undefined, + duration, + bodyPreview: bodyText ? truncateText(redactAIUpstreamLogPreview(bodyText), bodyPreviewLimit) : undefined, + error: errorText ? truncateText(redactAIUpstreamLogPreview(errorText), 2000) : undefined, + line: truncateText(redactAIUpstreamLogPreview(line), 4000), + }; +}; + +const matchesFilter = (event: AIUpstreamLogEvent, params: { + provider?: unknown; + requestId?: unknown; + keyword?: unknown; +}): boolean => { + const provider = String(params.provider || '').trim().toLowerCase(); + if (provider && event.provider.toLowerCase() !== provider) { + return false; + } + const requestId = String(params.requestId || '').trim().toLowerCase(); + if (requestId && event.requestId.toLowerCase() !== requestId) { + return false; + } + const keyword = String(params.keyword || '').trim().toLowerCase(); + if (!keyword) { + return true; + } + return [ + event.requestId, + event.provider, + event.method, + event.endpoint, + event.bodyPreview, + event.error, + ].some((value) => String(value || '').toLowerCase().includes(keyword)); +}; + +const summarizeAIUpstreamRequests = ( + events: AIUpstreamLogEvent[], + includeBody: boolean, + requestLimit: number, +): AIUpstreamRequestSummary[] => { + const requestMap = new Map(); + events.forEach((event) => { + const requestKey = event.requestId || `line-${requestMap.size + 1}`; + const existing = requestMap.get(requestKey) || { + requestId: event.requestId, + provider: event.provider, + method: event.method, + endpoint: event.endpoint, + state: 'unknown' as const, + eventCount: 0, + hasBody: false, + }; + existing.provider = event.provider || existing.provider; + existing.method = event.method || existing.method; + existing.endpoint = event.endpoint || existing.endpoint; + existing.eventCount += 1; + if (event.type === 'started') existing.state = 'started'; + if (event.type === 'completed') existing.state = 'completed'; + if (event.type === 'failed') existing.state = 'failed'; + if (event.status !== undefined) existing.status = event.status; + if (event.duration) existing.duration = event.duration; + if (event.bodyPreview) { + existing.hasBody = true; + if (includeBody) existing.bodyPreview = event.bodyPreview; + } + if (event.error) existing.error = event.error; + requestMap.set(requestKey, existing); + }); + return Array.from(requestMap.values()).slice(-requestLimit); +}; + +export const buildAIUpstreamLogSnapshot = (params: { + readResult?: any; + provider?: unknown; + requestId?: unknown; + keyword?: unknown; + lineLimit?: unknown; + requestLimit?: unknown; + includeBody?: unknown; + includeLines?: unknown; + bodyPreviewLimit?: unknown; +}) => { + const data = params.readResult?.data && typeof params.readResult.data === 'object' + ? params.readResult.data as Record + : {}; + const requestedLineLimit = clampNumber(data.requestedLineLimit ?? params.lineLimit, DEFAULT_AI_UPSTREAM_LOG_LIMIT, 1, MAX_AI_UPSTREAM_LOG_LIMIT); + const requestLimit = clampNumber(params.requestLimit, DEFAULT_AI_UPSTREAM_REQUEST_LIMIT, 1, MAX_AI_UPSTREAM_REQUEST_LIMIT); + const bodyPreviewLimit = clampNumber(params.bodyPreviewLimit, DEFAULT_AI_UPSTREAM_BODY_LIMIT, 200, MAX_AI_UPSTREAM_BODY_LIMIT); + const includeBody = params.includeBody !== false; + const includeLines = params.includeLines === true; + const lines = normalizeLogLines(data.lines); + const parsedEvents = lines + .map((line) => parseAIUpstreamLogEvent(line, bodyPreviewLimit)) + .filter((event): event is AIUpstreamLogEvent => Boolean(event)) + .filter((event) => matchesFilter(event, params)); + const eventBreakdown = { + started: parsedEvents.filter((event) => event.type === 'started').length, + completed: parsedEvents.filter((event) => event.type === 'completed').length, + failed: parsedEvents.filter((event) => event.type === 'failed').length, + other: parsedEvents.filter((event) => event.type === 'other').length, + }; + const providers = Array.from(new Set(parsedEvents.map((event) => event.provider).filter(Boolean))); + const requestIds = Array.from(new Set(parsedEvents.map((event) => event.requestId).filter(Boolean))); + const requests = summarizeAIUpstreamRequests(parsedEvents, includeBody, requestLimit); + + return { + logPath: String(data.logPath || ''), + keyword: String(data.keyword || params.requestId || params.provider || params.keyword || '').trim(), + providerFilter: String(params.provider || '').trim(), + requestIdFilter: String(params.requestId || '').trim(), + requestedLineLimit, + returnedLineCount: lines.length, + upstreamEventCount: parsedEvents.length, + requestCount: requests.length, + eventBreakdown, + providers, + requestIds, + requests, + lines: includeLines ? parsedEvents.map((event) => event.line) : undefined, + message: parsedEvents.length > 0 + ? '' + : '最近日志里没有找到 AI 上游请求记录;请先发送一次 AI 消息,或扩大 lineLimit 后重试。', + nextActions: parsedEvents.length > 0 + ? [ + '如需核对完整入参,先用 requestId 精确过滤,再查看 bodyPreview 是否已被截断。', + '如果只有开始没有完成/失败,继续查看 inspect_app_logs 或扩大 lineLimit 排查请求是否超时。', + ] + : [ + '确认当前构建已包含 AI 上游请求日志能力。', + '发送一次 AI 聊天消息后再调用本工具。', + '如果仍没有记录,调用 inspect_app_logs 读取最近 WARN/ERROR 原文。', + ], + }; +}; diff --git a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts index 0acd1b7..de7a941 100644 --- a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts @@ -148,6 +148,35 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_ai_upstream_logs", + icon: "📡", + desc: "查看 AI 上游请求入参与状态", + detail: + "从 gonavi.log 读取最近的 AI 上游请求开始/完成/失败记录,按 provider、requestId 或关键词过滤,返回请求体 body 预览、endpoint、状态码、耗时和错误摘要。适合用户想核对发给上游模型的真实入参、排查请求参数兼容、确认脱敏日志是否写入时先调用。", + params: "provider?, requestId?, keyword?, lineLimit?(默认 160), requestLimit?(默认 12), includeBody?(默认 true), includeLines?(默认 false)", + tool: { + type: "function", + function: { + name: "inspect_ai_upstream_logs", + description: + "读取 GoNavi 应用日志中的 AI 上游请求记录,返回 requestId、provider、method、endpoint、请求 body 预览、状态码、耗时和错误摘要。适用于用户提到 AI 请求入参、上游请求体、requestId、provider 请求参数、模型接口报错、或需要核对刚才发给上游模型的真实 payload 时,先读取该工具,不要只凭界面响应推断。", + parameters: { + type: "object", + properties: { + provider: { type: "string", description: "可选,只看某个供应商,例如 openai、anthropic、gemini;大小写不敏感" }, + requestId: { type: "string", description: "可选,按日志里的 requestId 精确过滤,适合从错误日志继续追踪同一次请求" }, + keyword: { type: "string", description: "可选,在 requestId、provider、endpoint、bodyPreview 或 error 中继续过滤,例如模型名、接口路径、参数名" }, + lineLimit: { type: "number", description: "可选,最多读取多少行日志尾部,默认 160,最大 300" }, + requestLimit: { type: "number", description: "可选,最多返回多少个请求摘要,默认 12,最大 40" }, + includeBody: { type: "boolean", description: "可选,是否返回已脱敏的请求 body 预览,默认 true;只看状态时可设为 false" }, + includeLines: { type: "boolean", description: "可选,是否附带脱敏后的原始日志行,默认 false;需要引用原文时再开启" }, + bodyPreviewLimit: { type: "number", description: "可选,单个 body 预览最大字符数,默认 6000,最大 12000" }, + }, + }, + }, + }, + }, { name: "inspect_ai_tool_catalog", icon: "🧭", diff --git a/frontend/src/utils/aiBuiltinToolCatalog.ts b/frontend/src/utils/aiBuiltinToolCatalog.ts index 13e7945..7d0fb41 100644 --- a/frontend/src/utils/aiBuiltinToolCatalog.ts +++ b/frontend/src/utils/aiBuiltinToolCatalog.ts @@ -79,6 +79,11 @@ export const BUILTIN_TOOL_FLOWS: AIBuiltinToolFlow[] = [ steps: 'inspect_ai_chat_readiness -> inspect_ai_providers', description: '适合先确认当前聊天输入区到底缺什么前置条件,例如没选活动供应商、缺密钥、缺接口地址、没选模型,避免只凭界面现象猜测。', }, + { + title: '追踪 AI 上游请求', + steps: 'inspect_ai_upstream_logs -> inspect_ai_providers / inspect_ai_message_flow', + description: '适合用户想看发给上游模型的真实入参、requestId、状态码、耗时或请求体预览时,先读脱敏后的 gonavi.log 请求记录,再结合供应商配置和当前消息流继续排查。', + }, { title: '排查 MCP 接入状态', steps: 'inspect_mcp_setup -> inspect_ai_runtime', diff --git a/frontend/src/utils/aiComposerNotice.test.ts b/frontend/src/utils/aiComposerNotice.test.ts index 33404f8..912b831 100644 --- a/frontend/src/utils/aiComposerNotice.test.ts +++ b/frontend/src/utils/aiComposerNotice.test.ts @@ -12,6 +12,10 @@ describe('ai composer notice helpers', () => { tone: 'warning', title: '还没有可用供应商', description: '先在 AI 设置里添加并启用一个模型供应商。', + action: { + key: 'open-settings', + label: '打开 AI 设置', + }, }); }); @@ -20,6 +24,10 @@ describe('ai composer notice helpers', () => { tone: 'warning', title: '先选择一个模型', description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。', + action: { + key: 'reload-models', + label: '重新加载模型', + }, }); }); @@ -28,6 +36,10 @@ describe('ai composer notice helpers', () => { tone: 'error', title: '模型列表加载失败', description: '当前接口未返回可用模型', + action: { + key: 'reload-models', + label: '重新加载模型', + }, }); }); }); diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index c62c9fc..55cf94c 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -84,6 +84,14 @@ describe('aiToolRegistry', () => { expect(info?.tool.function.description).toContain('当前 AI 聊天输入区'); }); + it('registers the ai-upstream-log inspector as a builtin tool', () => { + const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_upstream_logs'); + expect(info).toBeTruthy(); + expect(info?.desc).toContain('上游请求入参'); + expect(info?.tool.function.description).toContain('请求 body 预览'); + expect(info?.tool.function.parameters?.properties?.requestId?.description).toContain('requestId'); + }); + it('registers the ai-tool-catalog inspector as a builtin tool', () => { const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_tool_catalog'); expect(info).toBeTruthy(); @@ -234,6 +242,7 @@ describe('aiToolRegistry', () => { expect(tools.some((item) => item.function.name === 'inspect_ai_safety')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_providers')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_chat_readiness')).toBe(true); + expect(tools.some((item) => item.function.name === 'inspect_ai_upstream_logs')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_tool_catalog')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_mcp_setup')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_mcp_remote_access')).toBe(true);