mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 10:29:52 +08:00
✨ feat(ai): 新增上游请求日志自查工具
This commit is contained in:
@@ -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 填写指引');
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): 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');
|
||||
});
|
||||
});
|
||||
@@ -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 消息流诊断失败',
|
||||
|
||||
@@ -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 读取真实新增指引和模板');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, any>
|
||||
@@ -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,
|
||||
|
||||
249
frontend/src/components/ai/aiUpstreamLogInsights.ts
Normal file
249
frontend/src/components/ai/aiUpstreamLogInsights.ts
Normal file
@@ -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<string, AIUpstreamRequestSummary>();
|
||||
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<string, unknown>
|
||||
: {};
|
||||
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 原文。',
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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: "🧭",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '重新加载模型',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user