Spaces:
Running
Running
github-actions[bot]
sync: upstream b70f787 Merge pull request #84 from huangzt/feature/vue-logs-ui
c6dedd5 | /** | |
| * logger.ts - 全链路日志系统 v4 | |
| * | |
| * 核心升级: | |
| * - 存储完整的请求参数(messages, system prompt, tools) | |
| * - 存储完整的模型返回内容(raw response) | |
| * - 存储转换后的 Cursor 请求 | |
| * - 阶段耗时追踪 (Phase Timing) | |
| * - TTFT (Time To First Token) | |
| * - 用户问题标题提取 | |
| * - 日志文件持久化(JSONL 格式,可配置开关) | |
| * - 日志清空操作 | |
| * - 全部通过 Web UI 可视化 | |
| */ | |
| import { EventEmitter } from 'events'; | |
| import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs'; | |
| import { join, basename } from 'path'; | |
| import { getConfig } from './config.js'; | |
| // ==================== 类型定义 ==================== | |
| export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; | |
| export type LogSource = 'Handler' | 'OpenAI' | 'Cursor' | 'Auth' | 'System' | 'Converter'; | |
| export type LogPhase = | |
| | 'receive' | 'auth' | 'convert' | 'intercept' | 'send' | |
| | 'response' | 'refusal' | 'retry' | 'truncation' | 'continuation' | |
| | 'thinking' | 'toolparse' | 'sanitize' | 'stream' | 'complete' | 'error'; | |
| export interface LogEntry { | |
| id: string; | |
| requestId: string; | |
| timestamp: number; | |
| level: LogLevel; | |
| source: LogSource; | |
| phase: LogPhase; | |
| message: string; | |
| details?: unknown; | |
| duration?: number; | |
| } | |
| export interface PhaseTiming { | |
| phase: LogPhase; | |
| label: string; | |
| startTime: number; | |
| endTime?: number; | |
| duration?: number; | |
| } | |
| /** | |
| * 完整请求数据 — 存储每个请求的全量参数和响应 | |
| */ | |
| export interface RequestPayload { | |
| // ===== 原始请求 ===== | |
| /** 原始请求 body(Anthropic 或 OpenAI 格式) */ | |
| originalRequest?: unknown; | |
| /** System prompt(提取出来方便查看) */ | |
| systemPrompt?: string; | |
| /** 用户消息列表摘要 */ | |
| messages?: Array<{ role: string; contentPreview: string; contentLength: number; hasImages?: boolean }>; | |
| /** 工具定义列表 */ | |
| tools?: Array<{ name: string; description?: string }>; | |
| // ===== 转换后请求 ===== | |
| /** 转换后的 Cursor 请求 */ | |
| cursorRequest?: unknown; | |
| /** Cursor 消息列表摘要 */ | |
| cursorMessages?: Array<{ role: string; contentPreview: string; contentLength: number }>; | |
| // ===== 模型响应 ===== | |
| /** 原始模型返回全文 */ | |
| rawResponse?: string; | |
| /** 清洗/处理后的最终响应 */ | |
| finalResponse?: string; | |
| /** Thinking 内容 */ | |
| thinkingContent?: string; | |
| /** 工具调用解析结果 */ | |
| toolCalls?: unknown[]; | |
| /** 每次重试的原始响应 */ | |
| retryResponses?: Array<{ attempt: number; response: string; reason: string }>; | |
| /** 每次续写的原始响应 */ | |
| continuationResponses?: Array<{ index: number; response: string; dedupedLength: number }>; | |
| /** summary 模式:最后一个用户问题 */ | |
| question?: string; | |
| /** summary 模式:最终回答摘要 */ | |
| answer?: string; | |
| /** summary 模式:回答类型 */ | |
| answerType?: 'text' | 'tool_calls' | 'empty'; | |
| /** summary 模式:工具调用名称列表 */ | |
| toolCallNames?: string[]; | |
| } | |
| export interface RequestSummary { | |
| requestId: string; | |
| startTime: number; | |
| endTime?: number; | |
| method: string; | |
| path: string; | |
| model: string; | |
| stream: boolean; | |
| apiFormat: 'anthropic' | 'openai' | 'responses'; | |
| hasTools: boolean; | |
| toolCount: number; | |
| messageCount: number; | |
| status: 'processing' | 'success' | 'error' | 'intercepted'; | |
| responseChars: number; | |
| retryCount: number; | |
| continuationCount: number; | |
| stopReason?: string; | |
| error?: string; | |
| toolCallsDetected: number; | |
| ttft?: number; | |
| cursorApiTime?: number; | |
| phaseTimings: PhaseTiming[]; | |
| thinkingChars: number; | |
| systemPromptLength: number; | |
| /** 用户提问标题(截取最后一个 user 消息的前 80 字符) */ | |
| title?: string; | |
| } | |
| // ==================== 存储 ==================== | |
| const MAX_ENTRIES = 5000; | |
| const MAX_REQUESTS = 200; | |
| let logCounter = 0; | |
| const logEntries: LogEntry[] = []; | |
| const requestSummaries: Map<string, RequestSummary> = new Map(); | |
| const requestPayloads: Map<string, RequestPayload> = new Map(); | |
| const requestOrder: string[] = []; | |
| const logEmitter = new EventEmitter(); | |
| logEmitter.setMaxListeners(50); | |
| function shortId(): string { | |
| const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; | |
| let id = ''; | |
| for (let i = 0; i < 8; i++) id += chars[Math.floor(Math.random() * chars.length)]; | |
| return id; | |
| } | |
| // ==================== 日志文件持久化 ==================== | |
| const DEFAULT_PERSIST_MODE: 'compact' | 'full' | 'summary' = 'summary'; | |
| const DISK_SYSTEM_PROMPT_CHARS = 2000; | |
| const DISK_MESSAGE_PREVIEW_CHARS = 3000; | |
| const DISK_CURSOR_MESSAGE_PREVIEW_CHARS = 2000; | |
| const DISK_RESPONSE_CHARS = 8000; | |
| const DISK_THINKING_CHARS = 4000; | |
| const DISK_TOOL_DESC_CHARS = 500; | |
| const DISK_RETRY_CHARS = 2000; | |
| const DISK_TOOLCALL_STRING_CHARS = 1200; | |
| const DISK_MAX_ARRAY_ITEMS = 20; | |
| const DISK_MAX_OBJECT_DEPTH = 5; | |
| const DISK_SUMMARY_QUESTION_CHARS = 2000; | |
| const DISK_SUMMARY_ANSWER_CHARS = 4000; | |
| function getLogDir(): string | null { | |
| const cfg = getConfig(); | |
| if (!cfg.logging?.file_enabled) return null; | |
| return cfg.logging.dir || './logs'; | |
| } | |
| function getPersistMode(): 'compact' | 'full' | 'summary' { | |
| const mode = getConfig().logging?.persist_mode; | |
| return mode === 'full' || mode === 'summary' || mode === 'compact' ? mode : DEFAULT_PERSIST_MODE; | |
| } | |
| function getLogFilePath(): string | null { | |
| const dir = getLogDir(); | |
| if (!dir) return null; | |
| const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD | |
| return join(dir, `cursor2api-${date}.jsonl`); | |
| } | |
| function ensureLogDir(): void { | |
| const dir = getLogDir(); | |
| if (dir && !existsSync(dir)) { | |
| mkdirSync(dir, { recursive: true }); | |
| } | |
| } | |
| function truncateMiddle(text: string, maxChars: number): string { | |
| if (!text || text.length <= maxChars) return text; | |
| const omitted = text.length - maxChars; | |
| const marker = `\n...[截断 ${omitted} chars]...\n`; | |
| const remain = Math.max(16, maxChars - marker.length); | |
| const head = Math.ceil(remain * 0.7); | |
| const tail = Math.max(8, remain - head); | |
| return text.slice(0, head) + marker + text.slice(text.length - tail); | |
| } | |
| function compactUnknownValue(value: unknown, maxStringChars = DISK_TOOLCALL_STRING_CHARS, depth = 0): unknown { | |
| if (value === null || value === undefined) return value; | |
| if (typeof value === 'string') return truncateMiddle(value, maxStringChars); | |
| if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return value; | |
| if (depth >= DISK_MAX_OBJECT_DEPTH) { | |
| if (Array.isArray(value)) return `[array(${value.length})]`; | |
| return '[object]'; | |
| } | |
| if (Array.isArray(value)) { | |
| const items = value.slice(0, DISK_MAX_ARRAY_ITEMS) | |
| .map(item => compactUnknownValue(item, maxStringChars, depth + 1)); | |
| if (value.length > DISK_MAX_ARRAY_ITEMS) { | |
| items.push(`[... ${value.length - DISK_MAX_ARRAY_ITEMS} more items]`); | |
| } | |
| return items; | |
| } | |
| if (typeof value === 'object') { | |
| const result: Record<string, unknown> = {}; | |
| for (const [key, entry] of Object.entries(value as Record<string, unknown>)) { | |
| const limit = /content|text|arguments|description|prompt|response|reasoning/i.test(key) | |
| ? maxStringChars | |
| : Math.min(maxStringChars, 400); | |
| result[key] = compactUnknownValue(entry, limit, depth + 1); | |
| } | |
| return result; | |
| } | |
| return String(value); | |
| } | |
| function extractTextParts(value: unknown): string { | |
| if (typeof value === 'string') return value; | |
| if (!value) return ''; | |
| if (Array.isArray(value)) { | |
| return value | |
| .map(item => extractTextParts(item)) | |
| .filter(Boolean) | |
| .join('\n'); | |
| } | |
| if (typeof value === 'object') { | |
| const record = value as Record<string, unknown>; | |
| if (typeof record.text === 'string') return record.text; | |
| if (typeof record.output === 'string') return record.output; | |
| if (typeof record.content === 'string') return record.content; | |
| if (record.content !== undefined) return extractTextParts(record.content); | |
| if (record.input !== undefined) return extractTextParts(record.input); | |
| } | |
| return ''; | |
| } | |
| function extractLastUserQuestion(summary: RequestSummary, payload: RequestPayload): string | undefined { | |
| const lastUser = payload.messages?.slice().reverse().find(m => m.role === 'user' && m.contentPreview?.trim()); | |
| if (lastUser?.contentPreview) { | |
| return truncateMiddle(lastUser.contentPreview, DISK_SUMMARY_QUESTION_CHARS); | |
| } | |
| const original = payload.originalRequest && typeof payload.originalRequest === 'object' && !Array.isArray(payload.originalRequest) | |
| ? payload.originalRequest as Record<string, unknown> | |
| : undefined; | |
| if (!original) { | |
| return summary.title ? truncateMiddle(summary.title, DISK_SUMMARY_QUESTION_CHARS) : undefined; | |
| } | |
| if (Array.isArray(original.messages)) { | |
| for (let i = original.messages.length - 1; i >= 0; i--) { | |
| const item = original.messages[i] as Record<string, unknown>; | |
| if (item?.role === 'user') { | |
| const text = extractTextParts(item.content); | |
| if (text.trim()) return truncateMiddle(text, DISK_SUMMARY_QUESTION_CHARS); | |
| } | |
| } | |
| } | |
| if (typeof original.input === 'string' && original.input.trim()) { | |
| return truncateMiddle(original.input, DISK_SUMMARY_QUESTION_CHARS); | |
| } | |
| if (Array.isArray(original.input)) { | |
| for (let i = original.input.length - 1; i >= 0; i--) { | |
| const item = original.input[i] as Record<string, unknown>; | |
| if (!item) continue; | |
| const role = typeof item.role === 'string' ? item.role : 'user'; | |
| if (role === 'user') { | |
| const text = extractTextParts(item.content ?? item.input ?? item); | |
| if (text.trim()) return truncateMiddle(text, DISK_SUMMARY_QUESTION_CHARS); | |
| } | |
| } | |
| } | |
| return summary.title ? truncateMiddle(summary.title, DISK_SUMMARY_QUESTION_CHARS) : undefined; | |
| } | |
| function extractToolCallNames(payload: RequestPayload): string[] { | |
| if (!payload.toolCalls?.length) return []; | |
| return payload.toolCalls | |
| .map(call => { | |
| if (call && typeof call === 'object') { | |
| const record = call as Record<string, unknown>; | |
| if (typeof record.name === 'string') return record.name; | |
| const fn = record.function; | |
| if (fn && typeof fn === 'object' && typeof (fn as Record<string, unknown>).name === 'string') { | |
| return (fn as Record<string, unknown>).name as string; | |
| } | |
| } | |
| return ''; | |
| }) | |
| .filter(Boolean); | |
| } | |
| function buildSummaryPayload(summary: RequestSummary, payload: RequestPayload): RequestPayload { | |
| const question = extractLastUserQuestion(summary, payload); | |
| const answerText = payload.finalResponse || payload.rawResponse || ''; | |
| const toolCallNames = extractToolCallNames(payload); | |
| const answer = answerText | |
| ? truncateMiddle(answerText, DISK_SUMMARY_ANSWER_CHARS) | |
| : toolCallNames.length > 0 | |
| ? `[tool_calls] ${toolCallNames.join(', ')}` | |
| : undefined; | |
| return { | |
| ...(question ? { question } : {}), | |
| ...(answer ? { answer } : {}), | |
| answerType: answerText ? 'text' : toolCallNames.length > 0 ? 'tool_calls' : 'empty', | |
| ...(toolCallNames.length > 0 ? { toolCallNames } : {}), | |
| }; | |
| } | |
| function buildCompactOriginalRequest(summary: RequestSummary, payload: RequestPayload): Record<string, unknown> | undefined { | |
| const original = payload.originalRequest && typeof payload.originalRequest === 'object' && !Array.isArray(payload.originalRequest) | |
| ? payload.originalRequest as Record<string, unknown> | |
| : undefined; | |
| const result: Record<string, unknown> = { | |
| model: summary.model, | |
| stream: summary.stream, | |
| apiFormat: summary.apiFormat, | |
| messageCount: summary.messageCount, | |
| toolCount: summary.toolCount, | |
| }; | |
| if (summary.title) result.title = summary.title; | |
| if (payload.systemPrompt) result.systemPromptPreview = truncateMiddle(payload.systemPrompt, DISK_SYSTEM_PROMPT_CHARS); | |
| if (payload.messages?.some(m => m.hasImages)) result.hasImages = true; | |
| const lastUser = payload.messages?.slice().reverse().find(m => m.role === 'user'); | |
| if (lastUser?.contentPreview) { | |
| result.lastUserPreview = truncateMiddle(lastUser.contentPreview, 800); | |
| } | |
| if (original) { | |
| for (const key of ['temperature', 'top_p', 'max_tokens', 'max_completion_tokens', 'max_output_tokens']) { | |
| const value = original[key]; | |
| if (value !== undefined && typeof value !== 'object') result[key] = value; | |
| } | |
| if (typeof original.instructions === 'string') { | |
| result.instructions = truncateMiddle(original.instructions, 1200); | |
| } | |
| if (typeof original.system === 'string') { | |
| result.system = truncateMiddle(original.system, DISK_SYSTEM_PROMPT_CHARS); | |
| } | |
| } | |
| return Object.keys(result).length > 0 ? result : undefined; | |
| } | |
| function compactPayloadForDisk(summary: RequestSummary, payload: RequestPayload): RequestPayload { | |
| const compact: RequestPayload = {}; | |
| if (payload.originalRequest !== undefined) { | |
| compact.originalRequest = buildCompactOriginalRequest(summary, payload); | |
| } | |
| if (payload.systemPrompt) { | |
| compact.systemPrompt = truncateMiddle(payload.systemPrompt, DISK_SYSTEM_PROMPT_CHARS); | |
| } | |
| if (payload.messages?.length) { | |
| compact.messages = payload.messages.map(msg => ({ | |
| ...msg, | |
| contentPreview: truncateMiddle(msg.contentPreview, DISK_MESSAGE_PREVIEW_CHARS), | |
| })); | |
| } | |
| if (payload.tools?.length) { | |
| compact.tools = payload.tools.map(tool => ({ | |
| name: tool.name, | |
| ...(tool.description ? { description: truncateMiddle(tool.description, DISK_TOOL_DESC_CHARS) } : {}), | |
| })); | |
| } | |
| if (payload.cursorRequest !== undefined) { | |
| compact.cursorRequest = payload.cursorRequest; | |
| } | |
| if (payload.cursorMessages?.length) { | |
| compact.cursorMessages = payload.cursorMessages.map(msg => ({ | |
| ...msg, | |
| contentPreview: truncateMiddle(msg.contentPreview, DISK_CURSOR_MESSAGE_PREVIEW_CHARS), | |
| })); | |
| } | |
| const compactFinalResponse = payload.finalResponse | |
| ? truncateMiddle(payload.finalResponse, DISK_RESPONSE_CHARS) | |
| : undefined; | |
| const compactRawResponse = payload.rawResponse | |
| ? truncateMiddle(payload.rawResponse, DISK_RESPONSE_CHARS) | |
| : undefined; | |
| if (compactFinalResponse) compact.finalResponse = compactFinalResponse; | |
| if (compactRawResponse && compactRawResponse !== compactFinalResponse) { | |
| compact.rawResponse = compactRawResponse; | |
| } | |
| if (payload.thinkingContent) { | |
| compact.thinkingContent = truncateMiddle(payload.thinkingContent, DISK_THINKING_CHARS); | |
| } | |
| if (payload.toolCalls?.length) { | |
| compact.toolCalls = compactUnknownValue(payload.toolCalls) as unknown[]; | |
| } | |
| if (payload.retryResponses?.length) { | |
| compact.retryResponses = payload.retryResponses.map(item => ({ | |
| ...item, | |
| response: truncateMiddle(item.response, DISK_RETRY_CHARS), | |
| reason: truncateMiddle(item.reason, 300), | |
| })); | |
| } | |
| if (payload.continuationResponses?.length) { | |
| compact.continuationResponses = payload.continuationResponses.map(item => ({ | |
| ...item, | |
| response: truncateMiddle(item.response, DISK_RETRY_CHARS), | |
| })); | |
| } | |
| return compact; | |
| } | |
| /** 将已完成的请求写入日志文件 */ | |
| function persistRequest(summary: RequestSummary, payload: RequestPayload): void { | |
| const filepath = getLogFilePath(); | |
| if (!filepath) return; | |
| try { | |
| ensureLogDir(); | |
| const persistMode = getPersistMode(); | |
| const persistedPayload = persistMode === 'full' | |
| ? payload | |
| : persistMode === 'summary' | |
| ? buildSummaryPayload(summary, payload) | |
| : compactPayloadForDisk(summary, payload); | |
| const record = { timestamp: Date.now(), summary, payload: persistedPayload }; | |
| appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8'); | |
| } catch (e) { | |
| console.warn('[Logger] 写入日志文件失败:', e); | |
| } | |
| } | |
| /** 启动时从日志文件加载历史记录 */ | |
| export function loadLogsFromFiles(): void { | |
| const dir = getLogDir(); | |
| if (!dir || !existsSync(dir)) return; | |
| try { | |
| const cfg = getConfig(); | |
| const maxDays = cfg.logging?.max_days || 7; | |
| const cutoff = Date.now() - maxDays * 86400000; | |
| const files = readdirSync(dir) | |
| .filter(f => f.startsWith('cursor2api-') && f.endsWith('.jsonl')) | |
| .sort(); // 按日期排序 | |
| // 清理过期文件 | |
| for (const f of files) { | |
| const dateStr = f.replace('cursor2api-', '').replace('.jsonl', ''); | |
| const fileDate = new Date(dateStr).getTime(); | |
| if (fileDate < cutoff) { | |
| try { unlinkSync(join(dir, f)); } catch { /* ignore */ } | |
| continue; | |
| } | |
| } | |
| // 加载有效文件(最多最近2个文件) | |
| const validFiles = readdirSync(dir) | |
| .filter(f => f.startsWith('cursor2api-') && f.endsWith('.jsonl')) | |
| .sort() | |
| .slice(-2); | |
| let loaded = 0; | |
| for (const f of validFiles) { | |
| const content = readFileSync(join(dir, f), 'utf-8'); | |
| const lines = content.split('\n').filter(Boolean); | |
| for (const line of lines) { | |
| try { | |
| const record = JSON.parse(line); | |
| if (record.summary && record.summary.requestId) { | |
| const s = record.summary as RequestSummary; | |
| const p = record.payload as RequestPayload || {}; | |
| if (!requestSummaries.has(s.requestId)) { | |
| requestSummaries.set(s.requestId, s); | |
| requestPayloads.set(s.requestId, p); | |
| requestOrder.push(s.requestId); | |
| loaded++; | |
| } | |
| } | |
| } catch { /* skip malformed lines */ } | |
| } | |
| } | |
| // 裁剪到 MAX_REQUESTS | |
| while (requestOrder.length > MAX_REQUESTS) { | |
| const oldId = requestOrder.shift()!; | |
| requestSummaries.delete(oldId); | |
| requestPayloads.delete(oldId); | |
| } | |
| if (loaded > 0) { | |
| console.log(`[Logger] 从日志文件加载了 ${loaded} 条历史记录`); | |
| } | |
| } catch (e) { | |
| console.warn('[Logger] 加载日志文件失败:', e); | |
| } | |
| } | |
| /** 清空所有日志(内存 + 文件) */ | |
| export function clearAllLogs(): { cleared: number } { | |
| const count = requestSummaries.size; | |
| logEntries.length = 0; | |
| requestSummaries.clear(); | |
| requestPayloads.clear(); | |
| requestOrder.length = 0; | |
| logCounter = 0; | |
| // 清空日志文件 | |
| const dir = getLogDir(); | |
| if (dir && existsSync(dir)) { | |
| try { | |
| const files = readdirSync(dir).filter(f => f.startsWith('cursor2api-') && f.endsWith('.jsonl')); | |
| for (const f of files) { | |
| try { unlinkSync(join(dir, f)); } catch { /* ignore */ } | |
| } | |
| } catch { /* ignore */ } | |
| } | |
| return { cleared: count }; | |
| } | |
| // ==================== 统计 ==================== | |
| export function getStats() { | |
| let success = 0, error = 0, intercepted = 0, processing = 0; | |
| let totalTime = 0, timeCount = 0, totalTTFT = 0, ttftCount = 0; | |
| for (const s of requestSummaries.values()) { | |
| if (s.status === 'success') success++; | |
| else if (s.status === 'error') error++; | |
| else if (s.status === 'intercepted') intercepted++; | |
| else if (s.status === 'processing') processing++; | |
| if (s.endTime) { totalTime += s.endTime - s.startTime; timeCount++; } | |
| if (s.ttft) { totalTTFT += s.ttft; ttftCount++; } | |
| } | |
| return { | |
| totalRequests: requestSummaries.size, | |
| successCount: success, errorCount: error, | |
| interceptedCount: intercepted, processingCount: processing, | |
| avgResponseTime: timeCount > 0 ? Math.round(totalTime / timeCount) : 0, | |
| avgTTFT: ttftCount > 0 ? Math.round(totalTTFT / ttftCount) : 0, | |
| totalLogEntries: logEntries.length, | |
| }; | |
| } | |
| // ==================== 核心 API ==================== | |
| export function createRequestLogger(opts: { | |
| method: string; | |
| path: string; | |
| model: string; | |
| stream: boolean; | |
| hasTools: boolean; | |
| toolCount: number; | |
| messageCount: number; | |
| apiFormat?: 'anthropic' | 'openai' | 'responses'; | |
| systemPromptLength?: number; | |
| }): RequestLogger { | |
| const requestId = shortId(); | |
| const summary: RequestSummary = { | |
| requestId, startTime: Date.now(), | |
| method: opts.method, path: opts.path, model: opts.model, | |
| stream: opts.stream, | |
| apiFormat: opts.apiFormat || (opts.path.includes('chat/completions') ? 'openai' : | |
| opts.path.includes('responses') ? 'responses' : 'anthropic'), | |
| hasTools: opts.hasTools, toolCount: opts.toolCount, | |
| messageCount: opts.messageCount, | |
| status: 'processing', responseChars: 0, | |
| retryCount: 0, continuationCount: 0, toolCallsDetected: 0, | |
| phaseTimings: [], thinkingChars: 0, | |
| systemPromptLength: opts.systemPromptLength || 0, | |
| }; | |
| const payload: RequestPayload = {}; | |
| requestSummaries.set(requestId, summary); | |
| requestPayloads.set(requestId, payload); | |
| requestOrder.push(requestId); | |
| while (requestOrder.length > MAX_REQUESTS) { | |
| const oldId = requestOrder.shift()!; | |
| requestSummaries.delete(oldId); | |
| requestPayloads.delete(oldId); | |
| } | |
| const toolMode = (() => { | |
| const cfg = getConfig().tools; | |
| if (cfg?.disabled) return '(跳过)'; | |
| if (cfg?.passthrough) return '(透传)'; | |
| return ''; | |
| })(); | |
| const toolInfo = opts.hasTools ? ` tools=${opts.toolCount}${toolMode}` : ''; | |
| const fmtTag = summary.apiFormat === 'openai' ? ' [OAI]' : summary.apiFormat === 'responses' ? ' [RSP]' : ''; | |
| console.log(`\x1b[36m⟶\x1b[0m [${requestId}] ${opts.method} ${opts.path}${fmtTag} | model=${opts.model} stream=${opts.stream}${toolInfo} msgs=${opts.messageCount}`); | |
| return new RequestLogger(requestId, summary, payload); | |
| } | |
| export function getAllLogs(opts?: { requestId?: string; level?: LogLevel; source?: LogSource; limit?: number; since?: number }): LogEntry[] { | |
| let result = logEntries; | |
| if (opts?.requestId) result = result.filter(e => e.requestId === opts.requestId); | |
| if (opts?.level) { | |
| const levels: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 }; | |
| const minLevel = levels[opts.level]; | |
| result = result.filter(e => levels[e.level] >= minLevel); | |
| } | |
| if (opts?.source) result = result.filter(e => e.source === opts.source); | |
| if (opts?.since) result = result.filter(e => e.timestamp > opts!.since!); | |
| if (opts?.limit) result = result.slice(-opts.limit); | |
| return result; | |
| } | |
| export function getRequestSummaries(limit?: number): RequestSummary[] { | |
| const ids = limit ? requestOrder.slice(-limit) : requestOrder; | |
| return ids.map(id => requestSummaries.get(id)!).filter(Boolean).reverse(); | |
| } | |
| /** 获取请求的完整 payload 数据 */ | |
| export function getRequestPayload(requestId: string): RequestPayload | undefined { | |
| return requestPayloads.get(requestId); | |
| } | |
| export function subscribeToLogs(listener: (entry: LogEntry) => void): () => void { | |
| logEmitter.on('log', listener); | |
| return () => logEmitter.off('log', listener); | |
| } | |
| export function subscribeToSummaries(listener: (summary: RequestSummary) => void): () => void { | |
| logEmitter.on('summary', listener); | |
| return () => logEmitter.off('summary', listener); | |
| } | |
| function addEntry(entry: LogEntry): void { | |
| logEntries.push(entry); | |
| while (logEntries.length > MAX_ENTRIES) logEntries.shift(); | |
| logEmitter.emit('log', entry); | |
| } | |
| // ==================== RequestLogger ==================== | |
| export class RequestLogger { | |
| readonly requestId: string; | |
| private summary: RequestSummary; | |
| private payload: RequestPayload; | |
| private activePhase: PhaseTiming | null = null; | |
| constructor(requestId: string, summary: RequestSummary, payload: RequestPayload) { | |
| this.requestId = requestId; | |
| this.summary = summary; | |
| this.payload = payload; | |
| } | |
| private log(level: LogLevel, source: LogSource, phase: LogPhase, message: string, details?: unknown): void { | |
| addEntry({ | |
| id: `log_${++logCounter}`, | |
| requestId: this.requestId, | |
| timestamp: Date.now(), | |
| level, source, phase, message, details, | |
| duration: Date.now() - this.summary.startTime, | |
| }); | |
| } | |
| // ---- 阶段追踪 ---- | |
| startPhase(phase: LogPhase, label: string): void { | |
| if (this.activePhase && !this.activePhase.endTime) { | |
| this.activePhase.endTime = Date.now(); | |
| this.activePhase.duration = this.activePhase.endTime - this.activePhase.startTime; | |
| } | |
| const t: PhaseTiming = { phase, label, startTime: Date.now() }; | |
| this.activePhase = t; | |
| this.summary.phaseTimings.push(t); | |
| } | |
| endPhase(): void { | |
| if (this.activePhase && !this.activePhase.endTime) { | |
| this.activePhase.endTime = Date.now(); | |
| this.activePhase.duration = this.activePhase.endTime - this.activePhase.startTime; | |
| } | |
| } | |
| // ---- 便捷方法 ---- | |
| debug(source: LogSource, phase: LogPhase, message: string, details?: unknown): void { this.log('debug', source, phase, message, details); } | |
| info(source: LogSource, phase: LogPhase, message: string, details?: unknown): void { this.log('info', source, phase, message, details); } | |
| warn(source: LogSource, phase: LogPhase, message: string, details?: unknown): void { | |
| this.log('warn', source, phase, message, details); | |
| console.log(`\x1b[33m⚠\x1b[0m [${this.requestId}] ${message}`); | |
| } | |
| error(source: LogSource, phase: LogPhase, message: string, details?: unknown): void { | |
| this.log('error', source, phase, message, details); | |
| console.error(`\x1b[31m✗\x1b[0m [${this.requestId}] ${message}`); | |
| } | |
| // ---- 特殊事件 ---- | |
| recordTTFT(): void { this.summary.ttft = Date.now() - this.summary.startTime; } | |
| recordCursorApiTime(startTime: number): void { this.summary.cursorApiTime = Date.now() - startTime; } | |
| // ---- 全量数据记录 ---- | |
| /** 记录原始请求(包含 messages, system, tools 等) */ | |
| recordOriginalRequest(body: any): void { | |
| // system prompt | |
| if (typeof body.system === 'string') { | |
| this.payload.systemPrompt = body.system; | |
| } else if (Array.isArray(body.system)) { | |
| this.payload.systemPrompt = body.system.map((b: any) => b.text || '').join('\n'); | |
| } | |
| // messages 摘要 + 完整存储 | |
| if (Array.isArray(body.messages)) { | |
| const MAX_MSG = 100000; // 单条消息最大存储 100K | |
| this.payload.messages = body.messages.map((m: any) => { | |
| let fullContent = ''; | |
| let contentLength = 0; | |
| let hasImages = false; | |
| if (typeof m.content === 'string') { | |
| fullContent = m.content.length > MAX_MSG ? m.content.substring(0, MAX_MSG) + '\n... [截断]' : m.content; | |
| contentLength = m.content.length; | |
| } else if (Array.isArray(m.content)) { | |
| const textParts = m.content.filter((c: any) => c.type === 'text'); | |
| const imageParts = m.content.filter((c: any) => c.type === 'image' || c.type === 'image_url' || c.type === 'input_image'); | |
| hasImages = imageParts.length > 0; | |
| const text = textParts.map((c: any) => c.text || '').join('\n'); | |
| fullContent = text.length > MAX_MSG ? text.substring(0, MAX_MSG) + '\n... [截断]' : text; | |
| contentLength = text.length; | |
| if (hasImages) fullContent += `\n[+${imageParts.length} images]`; | |
| } | |
| return { role: m.role, contentPreview: fullContent, contentLength, hasImages }; | |
| }); | |
| // ★ 提取用户问题标题:取最后一个 user 消息的真实提问 | |
| const userMsgs = body.messages.filter((m: any) => m.role === 'user'); | |
| if (userMsgs.length > 0) { | |
| const lastUser = userMsgs[userMsgs.length - 1]; | |
| let text = ''; | |
| if (typeof lastUser.content === 'string') { | |
| text = lastUser.content; | |
| } else if (Array.isArray(lastUser.content)) { | |
| text = lastUser.content | |
| .filter((c: any) => c.type === 'text') | |
| .map((c: any) => c.text || '') | |
| .join(' '); | |
| } | |
| // 去掉 <system-reminder>...</system-reminder> 等 XML 注入内容 | |
| text = text.replace(/<[a-zA-Z_-]+>[\s\S]*?<\/[a-zA-Z_-]+>/gi, ''); | |
| // 去掉 Claude Code 尾部的引导语 | |
| text = text.replace(/First,\s*think\s+step\s+by\s+step[\s\S]*$/i, ''); | |
| text = text.replace(/Respond with the appropriate action[\s\S]*$/i, ''); | |
| // 清理换行、多余空格 | |
| text = text.replace(/\s+/g, ' ').trim(); | |
| this.summary.title = text.length > 80 ? text.substring(0, 77) + '...' : text; | |
| } | |
| } | |
| // tools — 完整记录,不截断描述(截断由 tools 配置控制,日志应保留原始信息) | |
| if (Array.isArray(body.tools)) { | |
| this.payload.tools = body.tools.map((t: any) => ({ | |
| name: t.name || t.function?.name || 'unknown', | |
| description: t.description || t.function?.description || '', | |
| })); | |
| } | |
| // 存全量 (去掉 base64 图片数据避免内存爆炸) | |
| this.payload.originalRequest = this.sanitizeForStorage(body); | |
| } | |
| /** 记录转换后的 Cursor 请求 */ | |
| recordCursorRequest(cursorReq: any): void { | |
| if (Array.isArray(cursorReq.messages)) { | |
| const MAX_MSG = 100000; | |
| this.payload.cursorMessages = cursorReq.messages.map((m: any) => { | |
| // Cursor 消息用 parts 而不是 content | |
| let text = ''; | |
| if (m.parts && Array.isArray(m.parts)) { | |
| text = m.parts.map((p: any) => p.text || '').join('\n'); | |
| } else if (typeof m.content === 'string') { | |
| text = m.content; | |
| } else if (m.content) { | |
| text = JSON.stringify(m.content); | |
| } | |
| const fullContent = text.length > MAX_MSG ? text.substring(0, MAX_MSG) + '\n... [截断]' : text; | |
| return { | |
| role: m.role, | |
| contentPreview: fullContent, | |
| contentLength: text.length, | |
| }; | |
| }); | |
| } | |
| // 存储不含完整消息体的 cursor 请求元信息 | |
| this.payload.cursorRequest = { | |
| model: cursorReq.model, | |
| messageCount: cursorReq.messages?.length, | |
| totalChars: cursorReq.messages?.reduce((sum: number, m: any) => { | |
| if (m.parts && Array.isArray(m.parts)) { | |
| return sum + m.parts.reduce((s: number, p: any) => s + (p.text?.length || 0), 0); | |
| } | |
| const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || ''); | |
| return sum + text.length; | |
| }, 0), | |
| }; | |
| } | |
| /** 记录模型原始响应 */ | |
| recordRawResponse(text: string): void { | |
| this.payload.rawResponse = text; | |
| } | |
| /** 记录最终响应 */ | |
| recordFinalResponse(text: string): void { | |
| this.payload.finalResponse = text; | |
| } | |
| /** 记录 thinking 内容 */ | |
| recordThinking(content: string): void { | |
| this.payload.thinkingContent = content; | |
| this.summary.thinkingChars = content.length; | |
| } | |
| /** 记录工具调用 */ | |
| recordToolCalls(calls: unknown[]): void { | |
| this.payload.toolCalls = calls; | |
| } | |
| /** 记录重试响应 */ | |
| recordRetryResponse(attempt: number, response: string, reason: string): void { | |
| if (!this.payload.retryResponses) this.payload.retryResponses = []; | |
| this.payload.retryResponses.push({ attempt, response, reason }); | |
| } | |
| /** 记录续写响应 */ | |
| recordContinuationResponse(index: number, response: string, dedupedLength: number): void { | |
| if (!this.payload.continuationResponses) this.payload.continuationResponses = []; | |
| this.payload.continuationResponses.push({ index, response: response.substring(0, 2000), dedupedLength }); | |
| } | |
| /** 去除 base64 图片数据以节省内存 */ | |
| private sanitizeForStorage(obj: any): any { | |
| if (!obj || typeof obj !== 'object') return obj; | |
| if (Array.isArray(obj)) return obj.map(item => this.sanitizeForStorage(item)); | |
| const result: any = {}; | |
| for (const [key, value] of Object.entries(obj)) { | |
| if (key === 'data' && typeof value === 'string' && (value as string).length > 1000) { | |
| result[key] = `[base64 data: ${(value as string).length} chars]`; | |
| } else if (key === 'source' && typeof value === 'object' && (value as any)?.type === 'base64') { | |
| result[key] = { type: 'base64', media_type: (value as any).media_type, data: `[${((value as any).data?.length || 0)} chars]` }; | |
| } else if (typeof value === 'object') { | |
| result[key] = this.sanitizeForStorage(value); | |
| } else { | |
| result[key] = value; | |
| } | |
| } | |
| return result; | |
| } | |
| // ---- 摘要更新 ---- | |
| updateSummary(updates: Partial<RequestSummary>): void { | |
| Object.assign(this.summary, updates); | |
| logEmitter.emit('summary', this.summary); | |
| } | |
| complete(responseChars: number, stopReason?: string): void { | |
| this.endPhase(); | |
| const duration = Date.now() - this.summary.startTime; | |
| this.summary.endTime = Date.now(); | |
| this.summary.status = 'success'; | |
| this.summary.responseChars = responseChars; | |
| this.summary.stopReason = stopReason; | |
| this.log('info', 'System', 'complete', `完成 (${duration}ms, ${responseChars} chars, stop=${stopReason})`); | |
| logEmitter.emit('summary', this.summary); | |
| // ★ 持久化到文件 | |
| persistRequest(this.summary, this.payload); | |
| const retryInfo = this.summary.retryCount > 0 ? ` retry=${this.summary.retryCount}` : ''; | |
| const contInfo = this.summary.continuationCount > 0 ? ` cont=${this.summary.continuationCount}` : ''; | |
| const toolInfo = this.summary.toolCallsDetected > 0 ? ` tools_called=${this.summary.toolCallsDetected}` : ''; | |
| const ttftInfo = this.summary.ttft ? ` ttft=${this.summary.ttft}ms` : ''; | |
| console.log(`\x1b[32m⟵\x1b[0m [${this.requestId}] ${duration}ms | ${responseChars} chars | stop=${stopReason || 'end_turn'}${ttftInfo}${retryInfo}${contInfo}${toolInfo}`); | |
| } | |
| intercepted(reason: string): void { | |
| this.summary.status = 'intercepted'; | |
| this.summary.endTime = Date.now(); | |
| this.log('info', 'System', 'intercept', reason); | |
| logEmitter.emit('summary', this.summary); | |
| persistRequest(this.summary, this.payload); | |
| console.log(`\x1b[35m⊘\x1b[0m [${this.requestId}] 拦截: ${reason}`); | |
| } | |
| fail(error: string): void { | |
| this.endPhase(); | |
| this.summary.status = 'error'; | |
| this.summary.endTime = Date.now(); | |
| this.summary.error = error; | |
| this.log('error', 'System', 'error', error); | |
| logEmitter.emit('summary', this.summary); | |
| persistRequest(this.summary, this.payload); | |
| } | |
| } | |