mirror of
https://github.com/7836246/cursor2api.git
synced 2026-05-07 14:17:49 +08:00
- 新增 getVueStats(since?)/apiGetVueStats/GET /api/vue/stats,供 Vue UI 专用 SQLite 模式走数据库全量聚合+since过滤,getStats() 保持原样不影响 HTML 版本 - Vue UI 所有 /api/stats 调用改为 /api/vue/stats - SSE stats 事件改为调 statsStore.load() 从 /api/vue/stats 拉最新数据 - 时间筛选增加 1小时/6小时选项,状态筛选改为 emoji 图标风格 - 状态筛选选中态:全部用蓝紫渐变底色,图标按钮用对应颜色边框+文字 - AppHeader/RequestList 缩写处全面补充 title tooltip - 新增「自动跟随」功能:选中记录后可开启,SSE 推送新请求时自动切换并滚动到顶部 - 修复 ConfigDrawer draft 可能为 null 的 TS 错误 - 修复 CSS typo:.rfmt.responses background 颜色值多余空格 - upsertRequest 改用 Object.assign 避免 SSE 推送时替换对象引用打断 hover
1155 lines
47 KiB
TypeScript
1155 lines
47 KiB
TypeScript
/**
|
||
* 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, onConfigReload } from './config.js';
|
||
import { initDb, closeDb, isDbInitialized, dbInsertRequest, dbGetPayload, dbGetSummaries, dbCountSummaries, dbGetSummaryCount, dbGetStatusCounts, dbGetSummariesSince, dbClear, dbGetStats } from './logger-db.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' | 'degraded' | 'error' | 'intercepted';
|
||
responseChars: number;
|
||
retryCount: number;
|
||
continuationCount: number;
|
||
stopReason?: string;
|
||
error?: string;
|
||
statusReason?: string;
|
||
issueTags?: string[];
|
||
toolCallsDetected: number;
|
||
ttft?: number;
|
||
cursorApiTime?: number;
|
||
phaseTimings: PhaseTiming[];
|
||
thinkingChars: number;
|
||
systemPromptLength: number;
|
||
inputTokens?: number; // 请求发出时的估算输入 token 数(js-tiktoken)
|
||
outputTokens?: number; // 响应完成后的估算输出 token 数(js-tiktoken)
|
||
/** 用户提问标题(截取最后一个 user 消息的前 80 字符) */
|
||
title?: string;
|
||
}
|
||
|
||
interface CompletionAssessment {
|
||
status: RequestSummary['status'];
|
||
statusReason?: string;
|
||
issueTags?: 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 } : {}),
|
||
};
|
||
}
|
||
|
||
const TOOL_UNAVAILABLE_PATTERNS: RegExp[] = [
|
||
/read-only documentation tools/i,
|
||
/documentation read tools/i,
|
||
/only documentation.*tools/i,
|
||
/\bi don't have (?:a |the )?(?:write|edit|bash)\b/i,
|
||
/\bi (?:can't|cannot) (?:create|write|save|edit|modify) files? directly\b/i,
|
||
/\bsave (?:this|it).+manually\b/i,
|
||
/只(?:有|能用).*(?:文档|只读).*(?:工具|tool)/,
|
||
/没有.*(?:Write|Bash|Edit).*工具/i,
|
||
/无法直接(?:创建|写入|保存|修改|编辑)文件/,
|
||
];
|
||
|
||
const SELF_REPAIR_AFTER_CUTOFF_PATTERNS: RegExp[] = [
|
||
/\b(?:file|response|output).{0,40}(?:got )?cut (?:off|short)\b/i,
|
||
/\bgot cut at line \d+\b/i,
|
||
/\bread what was written and complete it\b/i,
|
||
/\bappend the remaining (?:content|sections)\b/i,
|
||
/\bcomplete the remaining\b/i,
|
||
/文件.*(?:被截断|写到一半|没写完|写残)/,
|
||
/(?:补上|追加)剩余(?:内容|部分|章节)/,
|
||
/继续补全/,
|
||
];
|
||
|
||
function assessCompletionOutcome(summary: RequestSummary, payload: RequestPayload, stopReason?: string): CompletionAssessment {
|
||
const finalText = [payload.finalResponse, payload.rawResponse]
|
||
.find((text): text is string => typeof text === 'string' && text.trim().length > 0)
|
||
?.trim() || '';
|
||
|
||
const issueTags: string[] = [];
|
||
const reasonParts: string[] = [];
|
||
|
||
const missingToolExecution = summary.hasTools
|
||
&& summary.toolCallsDetected === 0
|
||
&& finalText.length > 0
|
||
&& TOOL_UNAVAILABLE_PATTERNS.some(pattern => pattern.test(finalText));
|
||
|
||
if (missingToolExecution) {
|
||
issueTags.push('tool_unavailable');
|
||
reasonParts.push('模型声称工具不可用,未执行实际工具调用');
|
||
}
|
||
|
||
const truncatedWithoutRecovery = stopReason === 'max_tokens' && summary.continuationCount === 0;
|
||
if (truncatedWithoutRecovery) {
|
||
issueTags.push('truncated_output');
|
||
reasonParts.push('响应触发 max_tokens 且未自动续写');
|
||
}
|
||
|
||
const selfRepairAfterCutoff = summary.hasTools
|
||
&& finalText.length > 0
|
||
&& SELF_REPAIR_AFTER_CUTOFF_PATTERNS.some(pattern => pattern.test(finalText));
|
||
if (selfRepairAfterCutoff) {
|
||
issueTags.push('self_repair_after_cutoff');
|
||
reasonParts.push('模型自述上一步输出或写入被截断,当前请求在补救补写');
|
||
}
|
||
|
||
if (issueTags.length > 0) {
|
||
return {
|
||
status: 'degraded',
|
||
statusReason: reasonParts.join(';'),
|
||
issueTags,
|
||
};
|
||
}
|
||
|
||
return { status: 'success' };
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/** 将已完成的请求写入日志文件和/或 SQLite */
|
||
function persistRequest(summary: RequestSummary, payload: RequestPayload): void {
|
||
// ---- 原有 JSONL 文件方式(保持不变)----
|
||
const filepath = getLogFilePath();
|
||
if (filepath) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ---- 新增 SQLite 方式 ----
|
||
const cfg = getConfig();
|
||
if (cfg.logging?.db_enabled) {
|
||
try {
|
||
dbInsertRequest(summary, payload);
|
||
} catch (e) {
|
||
console.warn('[Logger] 写入 SQLite 失败:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 启动时从日志文件和/或 SQLite 加载历史记录 */
|
||
export function loadLogsFromFiles(): void {
|
||
const cfg = getConfig();
|
||
|
||
// ---- 新增:SQLite 加载(只加载 summary,不加载 payload,彻底避免 OOM)----
|
||
if (cfg.logging?.db_enabled) {
|
||
try {
|
||
const maxDays = cfg.logging?.max_days || 7;
|
||
const cutoff = Date.now() - maxDays * 86400000;
|
||
// 初始化 SQLite(若尚未在 index.ts 中初始化则在此兜底)
|
||
try { initDb(cfg.logging.db_path || './logs/cursor2api.db'); } catch { /* already initialized */ }
|
||
const summaries = dbGetSummariesSince(cutoff);
|
||
let dbLoaded = 0;
|
||
for (const s of summaries) {
|
||
if (!requestSummaries.has(s.requestId)) {
|
||
requestSummaries.set(s.requestId, s as RequestSummary);
|
||
// 不预加载 payload,按需查询
|
||
requestOrder.push(s.requestId);
|
||
dbLoaded++;
|
||
}
|
||
}
|
||
// 裁剪到 MAX_REQUESTS(保留最新的)
|
||
while (requestOrder.length > MAX_REQUESTS) {
|
||
const oldId = requestOrder.shift()!;
|
||
requestSummaries.delete(oldId);
|
||
requestPayloads.delete(oldId);
|
||
}
|
||
if (dbLoaded > 0) {
|
||
console.log(`[Logger] 从 SQLite 加载了 ${dbLoaded} 条历史摘要(不含 payload)`);
|
||
}
|
||
} catch (e) {
|
||
console.warn('[Logger] 从 SQLite 加载失败:', e);
|
||
}
|
||
}
|
||
|
||
// ---- 原有 JSONL 文件加载(db_enabled 时跳过读取,避免 OOM;仅清理过期文件)----
|
||
const dir = getLogDir();
|
||
if (!dir || !existsSync(dir)) return;
|
||
try {
|
||
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;
|
||
}
|
||
}
|
||
|
||
// db_enabled 时跳过文件读取(SQLite 已加载 summary,避免 OOM)
|
||
if (!cfg.logging?.db_enabled) {
|
||
// 加载有效文件(最多最近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);
|
||
}
|
||
}
|
||
|
||
// ==================== SQLite 热重载 ====================
|
||
// 注册配置热重载回调,处理 db_enabled / db_path 运行时变更
|
||
onConfigReload((newCfg, changes) => {
|
||
// 只在 logging 配置变更时处理(避免其他字段变更触发不必要的 DB 重初始化)
|
||
if (!changes.some(c => c.startsWith('logging'))) return;
|
||
|
||
const dbEnabled = newCfg.logging?.db_enabled ?? false;
|
||
const dbPath = newCfg.logging?.db_path || './logs/cursor2api.db';
|
||
|
||
if (dbEnabled) {
|
||
// 启用或路径变更:重新初始化(initDb 内部会先关闭旧连接)
|
||
try {
|
||
initDb(dbPath);
|
||
console.log(`[Logger] SQLite 热重载:已初始化 ${dbPath}`);
|
||
} catch (e) {
|
||
console.warn('[Logger] SQLite 热重载初始化失败:', e);
|
||
}
|
||
} else {
|
||
// 禁用:关闭连接
|
||
if (isDbInitialized()) {
|
||
closeDb();
|
||
console.log('[Logger] SQLite 热重载:已关闭连接');
|
||
}
|
||
}
|
||
});
|
||
|
||
/** 清空所有日志(内存 + 文件) */
|
||
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 */ }
|
||
}
|
||
|
||
// 清空 SQLite
|
||
const cfg = getConfig();
|
||
if (cfg.logging?.db_enabled) {
|
||
try { dbClear(); } catch { /* ignore */ }
|
||
}
|
||
|
||
return { cleared: count };
|
||
}
|
||
|
||
// ==================== 统计 ====================
|
||
|
||
export function getStats() {
|
||
let success = 0, degraded = 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 === 'degraded') degraded++;
|
||
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, degradedCount: degraded, 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,
|
||
};
|
||
}
|
||
|
||
export function getVueStats(since?: number) {
|
||
const cfg = getConfig();
|
||
if (cfg.logging?.db_enabled) {
|
||
try {
|
||
return { ...dbGetStats(since), totalLogEntries: logEntries.length };
|
||
} catch (e) {
|
||
console.warn('[Logger] dbGetStats 失败,降级到内存:', e);
|
||
}
|
||
}
|
||
// 内存模式:since 参数忽略,数据本就有限,直接复用 getStats()
|
||
return getStats();
|
||
}
|
||
|
||
// ==================== 核心 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 {
|
||
// 先查内存
|
||
const cached = requestPayloads.get(requestId);
|
||
if (cached) return cached;
|
||
// 内存无(SQLite 模式下 payload 不预加载)→ 按需查 SQLite
|
||
const cfg = getConfig();
|
||
if (cfg.logging?.db_enabled) {
|
||
try { return dbGetPayload(requestId); } catch { /* ignore */ }
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* 游标分页查询请求摘要列表(仅 Vue UI 使用)。
|
||
* 支持 status/keyword/since 后端过滤,before 游标翻页。
|
||
* 结果按 startTime 倒序(最新在前)。
|
||
*/
|
||
export function getRequestSummariesPage(opts: {
|
||
limit: number;
|
||
before?: number;
|
||
status?: string;
|
||
keyword?: string;
|
||
since?: number;
|
||
}): { summaries: RequestSummary[]; hasMore: boolean; total: number; statusCounts: Record<string, number> } {
|
||
const { limit, before, status, keyword, since } = opts;
|
||
const cfg = getConfig();
|
||
|
||
if (cfg.logging?.db_enabled) {
|
||
// SQLite 支持完整历史翻页 + 后端过滤
|
||
try {
|
||
const summaries = dbGetSummaries({ limit: limit + 1, before, status, keyword, since }) as RequestSummary[];
|
||
const hasMore = summaries.length > limit;
|
||
return {
|
||
summaries: hasMore ? summaries.slice(0, limit) : summaries,
|
||
hasMore,
|
||
total: dbCountSummaries({ since, status, keyword }),
|
||
statusCounts: dbGetStatusCounts({ keyword, since }),
|
||
};
|
||
} catch (e) {
|
||
console.warn('[Logger] SQLite 分页查询失败:', e);
|
||
}
|
||
}
|
||
|
||
// 降级:从内存 requestOrder 切片(支持基本过滤)
|
||
// statusCounts 不受 status 过滤影响,单独计算
|
||
let allUnfiltered = requestOrder.slice().reverse();
|
||
if (since !== undefined) allUnfiltered = allUnfiltered.filter(id => (requestSummaries.get(id)?.startTime ?? 0) >= since);
|
||
if (keyword) {
|
||
const kw = keyword.toLowerCase();
|
||
allUnfiltered = allUnfiltered.filter(id => {
|
||
const s = requestSummaries.get(id);
|
||
return s && (
|
||
s.requestId.toLowerCase().includes(kw) ||
|
||
s.model.toLowerCase().includes(kw) ||
|
||
(s.title ?? '').toLowerCase().includes(kw)
|
||
);
|
||
});
|
||
}
|
||
const statusCounts: Record<string, number> = { all: allUnfiltered.length, success: 0, degraded: 0, error: 0, processing: 0, intercepted: 0 };
|
||
for (const id of allUnfiltered) {
|
||
const s = requestSummaries.get(id);
|
||
if (s?.status) statusCounts[s.status] = (statusCounts[s.status] ?? 0) + 1;
|
||
}
|
||
|
||
let all = status ? allUnfiltered.filter(id => requestSummaries.get(id)?.status === status) : allUnfiltered;
|
||
const startIdx = before !== undefined
|
||
? all.findIndex(id => (requestSummaries.get(id)?.startTime ?? Infinity) < before)
|
||
: 0;
|
||
const slice = startIdx >= 0 ? all.slice(startIdx, startIdx + limit + 1) : [];
|
||
const hasMore = slice.length > limit;
|
||
return {
|
||
summaries: slice.slice(0, limit).map(id => requestSummaries.get(id)!).filter(Boolean),
|
||
hasMore,
|
||
total: all.length,
|
||
statusCounts,
|
||
};
|
||
}
|
||
|
||
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;
|
||
const assessment = assessCompletionOutcome(this.summary, this.payload, stopReason);
|
||
this.summary.endTime = Date.now();
|
||
this.summary.status = assessment.status;
|
||
this.summary.statusReason = assessment.statusReason;
|
||
this.summary.issueTags = assessment.issueTags;
|
||
this.summary.responseChars = responseChars;
|
||
this.summary.stopReason = stopReason;
|
||
const completionMessage = assessment.status === 'degraded'
|
||
? `降级完成 (${duration}ms, ${responseChars} chars, stop=${stopReason})${assessment.statusReason ? ` - ${assessment.statusReason}` : ''}`
|
||
: `完成 (${duration}ms, ${responseChars} chars, stop=${stopReason})`;
|
||
this.log(assessment.status === 'degraded' ? 'warn' : 'info', 'System', 'complete', completionMessage);
|
||
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` : '';
|
||
const statusColor = assessment.status === 'degraded' ? '\x1b[33m' : '\x1b[32m';
|
||
const statusLabel = assessment.status === 'degraded' ? 'DEGRADED' : 'OK';
|
||
const reasonInfo = assessment.statusReason ? ` | reason=${assessment.statusReason}` : '';
|
||
console.log(`${statusColor}${statusLabel}\x1b[0m [${this.requestId}] ${duration}ms | ${responseChars} chars | stop=${stopReason || 'end_turn'}${ttftInfo}${retryInfo}${contInfo}${toolInfo}${reasonInfo}`);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|