diff --git a/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts index 680f7b2..a9ced1b 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts @@ -58,9 +58,59 @@ describe('aiLocalToolExecutor inspect_ai_upstream_logs', () => { expect(result.content).toContain('"status":200'); expect(result.content).toContain('"bodyPreview"'); expect(result.content).toContain('gpt-5.5'); + expect(result.content).toContain('"bodySummary"'); + expect(result.content).toContain('"messageCount":1'); + expect(result.content).toContain('"toolCount":0'); expect(result.content).not.toContain('abcdefghijklmnopqrstuvwxyz'); }); + it('summarizes payload shape without exposing prompt content when body preview is disabled', async () => { + const readAppLogTail = vi.fn().mockResolvedValue({ + success: true, + data: { + logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', + keyword: 'AI 上游请求', + requestedLineLimit: 160, + lines: [ + '2026/06/11 13:20:00.000000 [INFO] AI 上游请求开始:requestId=openai-tools-123 provider=openai method=POST endpoint=https://api.example.com/v1/chat/completions body={"model":"gpt-5.5","stream":true,"messages":[{"role":"system","content":"system secret password=abc123"},{"role":"user","content":"user private text"}],"tools":[{"type":"function","function":{"name":"inspect_app_health","description":"inspect app","parameters":{"type":"object","properties":{}}}}],"tool_choice":"auto","response_format":{"type":"json_object"},"api_key":"sk-should-not-leak"}', + '2026/06/11 13:20:01.000000 [INFO] AI 上游请求完成:requestId=openai-tools-123 provider=openai endpoint=https://api.example.com/v1/chat/completions status=200 duration=981ms', + ], + }, + }); + + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_upstream_logs', { + includeBody: false, + }), + connections: [], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + readAppLogTail, + }, + }); + + expect(result.success).toBe(true); + expect(readAppLogTail).toHaveBeenCalledWith(160, 'AI 上游请求'); + expect(result.content).toContain('"payloadSummaryEnabled":true'); + expect(result.content).not.toContain('"bodyPreview"'); + expect(result.content).not.toContain('password=abc123'); + expect(result.content).not.toContain('user private text'); + expect(result.content).not.toContain('sk-should-not-leak'); + expect(result.content).toContain('"bodySummary"'); + expect(result.content).toContain('"model":"gpt-5.5"'); + expect(result.content).toContain('"messageCount":2'); + expect(result.content).toContain('"system":1'); + expect(result.content).toContain('"user":1'); + expect(result.content).toContain('"toolCount":1'); + expect(result.content).toContain('"toolNames":["inspect_app_health"]'); + expect(result.content).toContain('"hasStream":true'); + expect(result.content).toContain('"hasToolChoice":true'); + expect(result.content).toContain('"hasResponseFormat":true'); + }); + 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', { diff --git a/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.ts index e47c922..713c5c2 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.ts @@ -191,6 +191,7 @@ export async function executeDiagnosticsSnapshotToolCall({ includeBody: args.includeBody !== false, includeLines: args.includeLines === true, bodyPreviewLimit: args.bodyPreviewLimit, + includePayloadSummary: args.includePayloadSummary !== false, })), success: true, }; diff --git a/frontend/src/components/ai/aiSystemInspectionGuidance.ts b/frontend/src/components/ai/aiSystemInspectionGuidance.ts index e672c6f..0700fa7 100644 --- a/frontend/src/components/ai/aiSystemInspectionGuidance.ts +++ b/frontend/src/components/ai/aiSystemInspectionGuidance.ts @@ -90,7 +90,7 @@ export const appendDatabaseInspectionGuidanceMessages = ( messages, availableToolNames, 'inspect_ai_upstream_logs', - '如果用户提到“AI 上游请求”“请求入参/请求体”“requestId”“发给模型的 payload”“上游接口报错具体传了什么”,优先调用 inspect_ai_upstream_logs 读取脱敏后的真实请求日志,再结合 inspect_ai_providers 或 inspect_ai_message_flow 继续定位。', + '如果用户提到“AI 上游请求”“请求入参/请求体”“requestId”“发给模型的 payload”“工具调用没触发”“上游接口报错具体传了什么”,优先调用 inspect_ai_upstream_logs 读取脱敏后的真实请求日志和 payload 结构摘要,再结合 inspect_ai_providers 或 inspect_ai_message_flow 继续定位。', ); appendGuidanceIfToolAvailable( messages, diff --git a/frontend/src/components/ai/aiUpstreamLogInsights.ts b/frontend/src/components/ai/aiUpstreamLogInsights.ts index f1ef50d..b9661ae 100644 --- a/frontend/src/components/ai/aiUpstreamLogInsights.ts +++ b/frontend/src/components/ai/aiUpstreamLogInsights.ts @@ -23,10 +23,27 @@ interface AIUpstreamLogEvent { status?: number; duration?: string; bodyPreview?: string; + bodySummary?: AIUpstreamPayloadSummary; error?: string; line: string; } +interface AIUpstreamPayloadSummary { + parseable: boolean; + parseError?: string; + topLevelKeys?: string[]; + model?: string; + messageCount?: number; + messageRoleCounts?: Record; + toolCount?: number; + toolNames?: string[]; + hasStream?: boolean; + hasToolChoice?: boolean; + hasResponseFormat?: boolean; + inputTextCharCount?: number; + warnings?: string[]; +} + interface AIUpstreamRequestSummary { requestId: string; provider: string; @@ -36,6 +53,7 @@ interface AIUpstreamRequestSummary { status?: number; duration?: string; bodyPreview?: string; + bodySummary?: AIUpstreamPayloadSummary; error?: string; eventCount: number; hasBody: boolean; @@ -68,6 +86,146 @@ const truncateText = (value: string, limit: number): string => { return `${value.slice(0, limit)}...[truncated ${value.length - limit} chars]`; }; +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const asRecordArray = (value: unknown): Record[] => + Array.isArray(value) ? value.filter(isRecord) : []; + +const stringValue = (value: unknown): string | undefined => + typeof value === 'string' && value.trim() ? value.trim() : undefined; + +const addName = (names: Set, value: unknown) => { + const normalized = stringValue(value); + if (normalized) { + names.add(normalized); + } +}; + +const extractToolNames = (body: Record): string[] => { + const names = new Set(); + asRecordArray(body.tools).forEach((tool) => { + addName(names, tool.name); + if (isRecord(tool.function)) { + addName(names, tool.function.name); + } + asRecordArray(tool.functionDeclarations).forEach((declaration) => addName(names, declaration.name)); + asRecordArray(tool.function_declarations).forEach((declaration) => addName(names, declaration.name)); + }); + asRecordArray(body.functions).forEach((fn) => addName(names, fn.name)); + return Array.from(names).slice(0, 30); +}; + +const countToolDefinitions = (body: Record, toolNames: string[]): number => { + const tools = asRecordArray(body.tools); + const functionDeclarationCount = tools.reduce((total, tool) => { + const camelDeclarations = asRecordArray(tool.functionDeclarations).length; + const snakeDeclarations = asRecordArray(tool.function_declarations).length; + return total + camelDeclarations + snakeDeclarations; + }, 0); + const genericToolCount = tools.length; + const legacyFunctionCount = asRecordArray(body.functions).length; + return functionDeclarationCount || legacyFunctionCount || genericToolCount || toolNames.length; +}; + +const normalizeMessageRole = (message: Record): string => { + const role = stringValue(message.role); + if (role) return role; + if (isRecord(message.author)) { + return stringValue(message.author.role) || 'unknown'; + } + return 'unknown'; +}; + +const getMessageLikeItems = (body: Record): Record[] => { + const messages = asRecordArray(body.messages); + if (messages.length > 0) return messages; + const contents = asRecordArray(body.contents); + if (contents.length > 0) return contents; + return []; +}; + +const addTextLength = (value: unknown): number => { + if (typeof value === 'string') return value.length; + if (Array.isArray(value)) return value.reduce((total, item) => total + addTextLength(item), 0); + if (!isRecord(value)) return 0; + return ['content', 'text', 'prompt', 'system', 'input'].reduce( + (total, key) => total + addTextLength(value[key]), + 0, + ); +}; + +const estimateInputTextChars = (body: Record): number => { + const messageItems = getMessageLikeItems(body); + const messageChars = messageItems.reduce((total, item) => total + addTextLength(item), 0); + return messageChars + + addTextLength(body.system) + + addTextLength(body.prompt) + + addTextLength(body.input); +}; + +const summarizeAIUpstreamPayload = (bodyText: string): AIUpstreamPayloadSummary => { + let parsed: unknown; + try { + parsed = JSON.parse(bodyText); + } catch (error: any) { + return { + parseable: false, + parseError: String(error?.message || error || 'invalid JSON'), + warnings: ['请求 body 不是完整 JSON,可能已被日志截断,无法生成结构化摘要'], + }; + } + + if (!isRecord(parsed)) { + return { + parseable: false, + parseError: 'request body is not a JSON object', + warnings: ['请求 body 不是 JSON object,无法识别模型、消息和工具字段'], + }; + } + + const topLevelKeys = Object.keys(parsed).slice(0, 30); + const messages = getMessageLikeItems(parsed); + const messageRoleCounts = messages.reduce>((acc, message) => { + const role = normalizeMessageRole(message); + acc[role] = (acc[role] || 0) + 1; + return acc; + }, {}); + if (parsed.system !== undefined) { + messageRoleCounts.system = (messageRoleCounts.system || 0) + 1; + } + const toolNames = extractToolNames(parsed); + const toolCount = countToolDefinitions(parsed, toolNames); + const messageCount = messages.length + (parsed.system !== undefined ? 1 : 0) + (parsed.prompt !== undefined ? 1 : 0); + const inputTextCharCount = estimateInputTextChars(parsed); + const warnings: string[] = []; + + if (messageCount === 0) { + warnings.push('未识别到 messages、contents、system 或 prompt 字段,请确认上游协议是否符合预期'); + } + if (toolCount === 0) { + warnings.push('payload 未携带 tools/functions,模型无法发起工具调用'); + } + if (inputTextCharCount > 60000) { + warnings.push('输入文本体量较大,必要时先收窄上下文或减少日志/DDL 内容'); + } + + return { + parseable: true, + topLevelKeys, + model: stringValue(parsed.model) || stringValue(parsed.modelName), + messageCount, + messageRoleCounts, + toolCount, + toolNames, + hasStream: parsed.stream === true || isRecord(parsed.stream_options), + hasToolChoice: parsed.tool_choice !== undefined || parsed.toolChoice !== undefined || parsed.toolConfig !== undefined, + hasResponseFormat: parsed.response_format !== undefined || parsed.responseFormat !== undefined || parsed.generationConfig !== undefined, + inputTextCharCount, + warnings: warnings.length > 0 ? warnings : undefined, + }; +}; + const extractField = (line: string, field: string): string => { const match = line.match(new RegExp(`(?:^|[\\s:])${field}=([^\\s]+)`)); return match?.[1] || ''; @@ -92,6 +250,7 @@ const resolveEventType = (line: string): AIUpstreamLogEventType => { const parseAIUpstreamLogEvent = ( line: string, bodyPreviewLimit: number, + includePayloadSummary: boolean, ): AIUpstreamLogEvent | null => { if (!line.includes('AI 上游请求')) { return null; @@ -116,6 +275,7 @@ const parseAIUpstreamLogEvent = ( status: statusText ? Number(statusText) : undefined, duration, bodyPreview: bodyText ? truncateText(redactAIUpstreamLogPreview(bodyText), bodyPreviewLimit) : undefined, + bodySummary: bodyText && includePayloadSummary ? summarizeAIUpstreamPayload(bodyText) : undefined, error: errorText ? truncateText(redactAIUpstreamLogPreview(errorText), 2000) : undefined, line: truncateText(redactAIUpstreamLogPreview(line), 4000), }; @@ -178,6 +338,7 @@ const summarizeAIUpstreamRequests = ( existing.hasBody = true; if (includeBody) existing.bodyPreview = event.bodyPreview; } + if (event.bodySummary) existing.bodySummary = event.bodySummary; if (event.error) existing.error = event.error; requestMap.set(requestKey, existing); }); @@ -194,6 +355,7 @@ export const buildAIUpstreamLogSnapshot = (params: { includeBody?: unknown; includeLines?: unknown; bodyPreviewLimit?: unknown; + includePayloadSummary?: unknown; }) => { const data = params.readResult?.data && typeof params.readResult.data === 'object' ? params.readResult.data as Record @@ -203,9 +365,10 @@ export const buildAIUpstreamLogSnapshot = (params: { 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 includePayloadSummary = params.includePayloadSummary !== false; const lines = normalizeLogLines(data.lines); const parsedEvents = lines - .map((line) => parseAIUpstreamLogEvent(line, bodyPreviewLimit)) + .map((line) => parseAIUpstreamLogEvent(line, bodyPreviewLimit, includePayloadSummary)) .filter((event): event is AIUpstreamLogEvent => Boolean(event)) .filter((event) => matchesFilter(event, params)); const eventBreakdown = { @@ -227,6 +390,7 @@ export const buildAIUpstreamLogSnapshot = (params: { returnedLineCount: lines.length, upstreamEventCount: parsedEvents.length, requestCount: requests.length, + payloadSummaryEnabled: includePayloadSummary, eventBreakdown, providers, requestIds, diff --git a/frontend/src/utils/aiBuiltinInspectionCoreToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionCoreToolInfo.ts index 064a2c0..c172084 100644 --- a/frontend/src/utils/aiBuiltinInspectionCoreToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionCoreToolInfo.ts @@ -153,14 +153,14 @@ export const BUILTIN_AI_INSPECTION_CORE_TOOL_INFO: AIBuiltinToolInfo[] = [ icon: "📡", desc: "查看 AI 上游请求入参与状态", detail: - "从 gonavi.log 读取最近的 AI 上游请求开始/完成/失败记录,按 provider、requestId 或关键词过滤,返回请求体 body 预览、endpoint、状态码、耗时和错误摘要。适合用户想核对发给上游模型的真实入参、排查请求参数兼容、确认脱敏日志是否写入时先调用。", - params: "provider?, requestId?, keyword?, lineLimit?(默认 160), requestLimit?(默认 12), includeBody?(默认 true), includeLines?(默认 false)", + "从 gonavi.log 读取最近的 AI 上游请求开始/完成/失败记录,按 provider、requestId 或关键词过滤,返回请求体 body 预览、payload 结构摘要、endpoint、状态码、耗时和错误摘要。适合用户想核对发给上游模型的真实入参、排查请求参数兼容、确认工具是否随请求下发或脱敏日志是否写入时先调用。", + params: "provider?, requestId?, keyword?, lineLimit?(默认 160), requestLimit?(默认 12), includeBody?(默认 true), includePayloadSummary?(默认 true), includeLines?(默认 false)", tool: { type: "function", function: { name: "inspect_ai_upstream_logs", description: - "读取 GoNavi 应用日志中的 AI 上游请求记录,返回 requestId、provider、method、endpoint、请求 body 预览、状态码、耗时和错误摘要。适用于用户提到 AI 请求入参、上游请求体、requestId、provider 请求参数、模型接口报错、或需要核对刚才发给上游模型的真实 payload 时,先读取该工具,不要只凭界面响应推断。", + "读取 GoNavi 应用日志中的 AI 上游请求记录,返回 requestId、provider、method、endpoint、请求 body 预览、脱敏 payload 结构摘要、状态码、耗时和错误摘要。适用于用户提到 AI 请求入参、上游请求体、requestId、provider 请求参数、工具调用没有触发、模型接口报错、或需要核对刚才发给上游模型的真实 payload 时,先读取该工具,不要只凭界面响应推断。", parameters: { type: "object", properties: { @@ -170,6 +170,7 @@ export const BUILTIN_AI_INSPECTION_CORE_TOOL_INFO: AIBuiltinToolInfo[] = [ lineLimit: { type: "number", description: "可选,最多读取多少行日志尾部,默认 160,最大 300" }, requestLimit: { type: "number", description: "可选,最多返回多少个请求摘要,默认 12,最大 40" }, includeBody: { type: "boolean", description: "可选,是否返回已脱敏的请求 body 预览,默认 true;只看状态时可设为 false" }, + includePayloadSummary: { type: "boolean", description: "可选,是否解析请求 body 并返回模型、消息角色分布、工具数量/名称、stream/tool_choice 等结构摘要,默认 true;不返回消息正文或密钥" }, includeLines: { type: "boolean", description: "可选,是否附带脱敏后的原始日志行,默认 false;需要引用原文时再开启" }, bodyPreviewLimit: { type: "number", description: "可选,单个 body 预览最大字符数,默认 6000,最大 12000" }, }, diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index ce57973..3a03c2b 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -99,6 +99,7 @@ describe('aiToolRegistry', () => { expect(info?.desc).toContain('上游请求入参'); expect(info?.tool.function.description).toContain('请求 body 预览'); expect(info?.tool.function.parameters?.properties?.requestId?.description).toContain('requestId'); + expect(info?.tool.function.parameters?.properties?.includePayloadSummary?.description).toContain('工具数量'); }); it('registers the ai-tool-catalog inspector as a builtin tool', () => {