diff --git a/README.md b/README.md
index dea3b55..e3af166 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,7 @@ cp config.yaml.example config.yaml
| `logging.file_enabled` | 日志文件持久化 | `false` |
| `logging.dir` | 日志存储目录 | `./logs` |
| `logging.max_days` | 日志保留天数 | `7` |
+| `logging.persist_mode` | 日志落盘模式:`summary` 问答摘要 / `compact` 精简 / `full` 完整 | `summary` |
| `max_auto_continue` | 截断自动续写次数 (`0`=禁用,交由客户端续写) | `0` |
| `sanitize_response` | 响应内容清洗开关(替换 Cursor 身份引用为 Claude) | `false` |
| `refusal_patterns` | 自定义拒绝检测规则列表(追加到内置规则) | 不配置 |
@@ -137,6 +138,8 @@ OPENAI_BASE_URL=http://localhost:3010/v1
- **阶段耗时** - 可视化时间线展示各阶段耗时(receive → convert → send → response → complete)
- **🌙 日/夜主题** - 一键切换明暗主题,自动记忆偏好
- **日志持久化** - 配置 `logging.file_enabled: true` 后日志写入 JSONL 文件,重启自动加载
+- **摘要落盘(默认)** - `logging.persist_mode: summary` 仅保留“用户问题 + 模型回答”与少量元数据,体积最小
+- **精简落盘** - `logging.persist_mode: compact` 保留更多排障字段,同时压缩磁盘 JSONL
### 鉴权
diff --git a/config.yaml.example b/config.yaml.example
index 27daf18..f500f9c 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -169,7 +169,7 @@ vision:
# ==================== 日志持久化配置(可选) ====================
# 开启后日志会写入文件,重启后自动加载历史记录
-# 环境变量: LOG_FILE_ENABLED=true|false, LOG_DIR=./logs
+# 环境变量: LOG_FILE_ENABLED=true|false, LOG_DIR=./logs, LOG_PERSIST_MODE=compact|full|summary
logging:
# 是否启用日志文件持久化(默认关闭)
file_enabled: false
@@ -177,3 +177,8 @@ logging:
dir: "./logs"
# 日志保留天数(超过天数的日志文件会自动清理)
max_days: 7
+ # 落盘模式:
+ # compact = 精简调试信息(保留更多排障细节)
+ # full = 完整持久化
+ # summary = 仅保留“用户问了什么 / 模型答了什么”与少量元数据(默认)
+ persist_mode: summary
diff --git a/public/logs.js b/public/logs.js
index 3b5c62d..1c2d894 100644
--- a/public/logs.js
+++ b/public/logs.js
@@ -262,6 +262,10 @@ function renderPromptsTab(tc){
}
// ===== 原始请求 =====
h+='
❓ 用户问题摘要 '+fmtN(curPayload.question.length)+' chars
';
+ h+='
'+escH(curPayload.question)+'
';
+ }
if(curPayload.systemPrompt){
h+='🔒 原始 System Prompt '+fmtN(curPayload.systemPrompt.length)+' chars
';
h+='
'+escH(curPayload.systemPrompt)+'
';
@@ -294,6 +298,15 @@ function renderPromptsTab(tc){
function renderResponseTab(tc){
if(!curPayload){tc.innerHTML=''+title+' '+fmtN(curPayload.answer.length)+' chars
';
+ h+='
'+escH(curPayload.answer)+'
';
+ }
+ if(curPayload.toolCallNames&&curPayload.toolCallNames.length&&!curPayload.toolCalls){
+ h+='🔧 工具调用名称 '+curPayload.toolCallNames.length+' 个
';
+ h+='
'+escH(curPayload.toolCallNames.join(', '))+'
';
+ }
if(curPayload.thinkingContent){
h+='🧠 Thinking 内容 '+fmtN(curPayload.thinkingContent.length)+' chars
';
h+='
'+escH(curPayload.thinkingContent)+'
';
diff --git a/src/config.ts b/src/config.ts
index 2e0b658..8877e68 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -74,10 +74,12 @@ function parseYamlConfig(defaults: AppConfig): { config: AppConfig; raw: Record<
}
// ★ 日志文件持久化
if (yaml.logging !== undefined) {
+ const persistModes = ['compact', 'full', 'summary'];
result.logging = {
file_enabled: yaml.logging.file_enabled === true, // 默认关闭
dir: yaml.logging.dir || './logs',
max_days: typeof yaml.logging.max_days === 'number' ? yaml.logging.max_days : 7,
+ persist_mode: persistModes.includes(yaml.logging.persist_mode) ? yaml.logging.persist_mode : 'summary',
};
}
// ★ 工具处理配置
@@ -139,13 +141,21 @@ function applyEnvOverrides(cfg: AppConfig): void {
}
// Logging 环境变量覆盖
if (process.env.LOG_FILE_ENABLED !== undefined) {
- if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7 };
+ if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary' };
cfg.logging.file_enabled = process.env.LOG_FILE_ENABLED === 'true' || process.env.LOG_FILE_ENABLED === '1';
}
if (process.env.LOG_DIR) {
- if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7 };
+ if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary' };
cfg.logging.dir = process.env.LOG_DIR;
}
+ if (process.env.LOG_PERSIST_MODE) {
+ if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary' };
+ cfg.logging.persist_mode = process.env.LOG_PERSIST_MODE === 'full'
+ ? 'full'
+ : process.env.LOG_PERSIST_MODE === 'summary'
+ ? 'summary'
+ : 'compact';
+ }
// 工具透传模式环境变量覆盖
if (process.env.TOOLS_PASSTHROUGH !== undefined) {
if (!cfg.tools) cfg.tools = { schemaMode: 'full', descriptionMaxLength: 0 };
diff --git a/src/index.ts b/src/index.ts
index 88e2dba..4a469f4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -153,7 +153,9 @@ loadLogsFromFiles();
app.listen(config.port, () => {
const auth = config.authTokens?.length ? `${config.authTokens.length} token(s)` : 'open';
- const logPersist = config.logging?.file_enabled ? `file → ${config.logging.dir}` : 'memory only';
+ const logPersist = config.logging?.file_enabled
+ ? `file(${config.logging.persist_mode || 'summary'}) → ${config.logging.dir}`
+ : 'memory only';
// Tools 配置摘要
const toolsCfg = config.tools;
diff --git a/src/logger.ts b/src/logger.ts
index 66a46c0..18f1aad 100644
--- a/src/logger.ts
+++ b/src/logger.ts
@@ -80,6 +80,14 @@ export interface RequestPayload {
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 {
@@ -133,12 +141,31 @@ function shortId(): string {
// ==================== 日志文件持久化 ====================
+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;
@@ -153,13 +180,256 @@ function ensureLogDir(): void {
}
}
+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