diff --git a/README.md b/README.md index a125b20..9ecb0e4 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,12 @@ cp config.yaml.example config.yaml | `vision.enabled` | 开启视觉拦截 | `true` | | `vision.mode` | 视觉模式:`ocr` / `api` | `ocr` | | `vision.proxy` | Vision 独立代理 | 不配置 | -| `logging.file_enabled` | 日志文件持久化 | `false` | +| `logging.file_enabled` | JSONL 文件持久化 | `false` | | `logging.dir` | 日志存储目录 | `./logs` | | `logging.max_days` | 日志保留天数 | `7` | | `logging.persist_mode` | 日志落盘模式:`summary` 问答摘要 / `compact` 精简 / `full` 完整 | `summary` | +| `logging.db_enabled` | SQLite 持久化(推荐,解决大文件 OOM) | `false` | +| `logging.db_path` | SQLite 文件路径 | `./logs/cursor2api.db` | | `max_auto_continue` | 截断自动续写次数 (`0`=禁用,交由客户端续写) | `0` | | `max_history_messages` | 历史消息条数上限,超出时删除最早消息(建议改用 `max_history_tokens`) | `-1`(不限制) | | `max_history_tokens` | 历史消息 token 数上限(推荐),代码自动补偿 Cursor 后端开销(1,300 基础 + 工具 tokenizer 差异),参考值 `130000~170000` | `150000` | @@ -90,6 +92,8 @@ cp config.yaml.example config.yaml | `tools.disabled` | 🆕 禁用模式:完全不注入工具定义,极致省上下文 | `false` | > 💡 详细配置说明请参见 `config.yaml.example` 中的注释。 +> +> ⚠️ `logging.*` 所有配置项修改后均需重启服务才能生效(不支持热重载)。 ### 3. 启动 @@ -139,9 +143,7 @@ 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 +- **日志持久化** - `logging.db_enabled: true` 开启 SQLite(推荐,解决大文件 OOM,重启后历史可查);或 `logging.file_enabled: true` 使用 JSONL 文件;两者可同时开启双写。`persist_mode` 控制落盘内容:`summary`(默认,仅问答摘要)/ `compact`(精简)/ `full`(完整) ### 鉴权 @@ -252,8 +254,10 @@ AI 按此格式输出 → 我们解析并转换为标准的 Anthropic `tool_use` | `THINKING_ENABLED` | Thinking 开关 (`true`/`false`) | | `COMPRESSION_ENABLED` | 压缩开关 (`true`/`false`) | | `COMPRESSION_LEVEL` | 压缩级别 (`1`/`2`/`3`) | -| `LOG_FILE_ENABLED` | 日志文件持久化 (`true`/`false`) | +| `LOG_FILE_ENABLED` | JSONL 文件持久化 (`true`/`false`) | | `LOG_DIR` | 日志文件目录 | +| `LOG_DB_ENABLED` | SQLite 持久化 (`true`/`false`),推荐替代 JSONL | +| `LOG_DB_PATH` | SQLite 文件路径 | | `MAX_AUTO_CONTINUE` | 截断自动续写次数 (`0`=禁用) | | `MAX_HISTORY_MESSAGES` | 历史消息条数上限(`-1`=不限制) | | `MAX_HISTORY_TOKENS` | 历史消息 token 数上限(默认 `150000`,`-1`=不限制,参考值 `130000~170000`,代码自动补偿 Cursor 后端开销) | diff --git a/config.yaml.example b/config.yaml.example index a5fb251..7993df2 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -200,10 +200,19 @@ vision: # proxy: "http://127.0.0.1:7890" # ==================== 日志持久化配置(可选) ==================== -# 开启后日志会写入文件,重启后自动加载历史记录 -# 环境变量: LOG_FILE_ENABLED=true|false, LOG_DIR=./logs, LOG_PERSIST_MODE=compact|full|summary +# 支持两种持久化方式,可单独开启或同时开启(双写)。 +# ⚠️ 以下所有配置项修改后需重启服务才能生效(不支持热重载) +# +# 方式一:JSONL 文件(每天一个文件,适合日志量较小的场景) +# 环境变量: LOG_FILE_ENABLED=true|false, LOG_DIR=./logs, LOG_PERSIST_MODE=compact|full|summary +# +# 方式二:SQLite 数据库(推荐,解决大文件 OOM 问题,支持重启后历史查询和分页) +# 优势:启动时仅加载 summary,payload 按需查询,彻底避免 OOM +# 优势:Vue UI 支持重启后翻页查看完整历史,搜索/筛选命中全量数据 +# 环境变量: LOG_DB_ENABLED=true|false, LOG_DB_PATH=./logs/cursor2api.db logging: - # 是否启用日志文件持久化(默认关闭) + # 方式一:JSONL 文件持久化(默认关闭) + # ⚠️ 单天日志量大时(>100MB)建议改用 SQLite 方式,避免启动 OOM file_enabled: false # 日志文件存储目录 dir: "./logs" @@ -211,6 +220,12 @@ logging: max_days: 7 # 落盘模式: # compact = 精简调试信息(保留更多排障细节) - # full = 完整持久化 - # summary = 仅保留“用户问了什么 / 模型答了什么”与少量元数据(默认) + # full = 完整持久化(文件体积最大,慎用) + # summary = 仅保留”用户问了什么 / 模型答了什么”与少量元数据(默认) persist_mode: summary + + # 方式二:SQLite 数据库持久化(推荐,默认关闭) + # ⚠️ 修改 db_enabled 或 db_path 后需重启服务才能生效 + db_enabled: false + # SQLite 文件路径(确保 logs 目录已挂载,Docker 下同 dir 目录) + db_path: "./logs/cursor2api.db" diff --git a/docker-compose.yml b/docker-compose.yml index 35ffeb7..96bfa71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,9 +41,13 @@ services: # - MAX_HISTORY_MESSAGES=-1 # 历史消息条数上限,-1=不限制(建议改用 MAX_HISTORY_TOKENS) # - MAX_HISTORY_TOKENS=150000 # 历史消息 token 数上限(推荐),默认 150000,参考值 130000~170000(代码自动补偿 Cursor 后端开销) - # ── 日志持久化 ── + # ── 日志持久化(⚠️ 修改后需重启容器生效) ── + # 方式一:JSONL 文件(日志量小时使用) # - LOG_FILE_ENABLED=true # - LOG_DIR=./logs + # 方式二:SQLite(推荐,避免大文件 OOM,支持重启后历史查询) + # - LOG_DB_ENABLED=true + # - LOG_DB_PATH=./logs/cursor2api.db # ── 浏览器指纹(base64 JSON) ── # - FP=eyJ1c2VyQWdlbnQiOiIuLi4ifQ== diff --git a/package-lock.json b/package-lock.json index 75e096d..bc354ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "cursor2api", "version": "2.7.6", "dependencies": { + "better-sqlite3": "^12.8.0", "dotenv": "^16.5.0", "eventsource-parser": "^3.0.1", "express": "^5.1.0", @@ -18,6 +19,7 @@ "yaml": "^2.7.1" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.2", "@types/node": "^22.15.0", "@types/uuid": "^10.0.0", @@ -467,6 +469,16 @@ "node": ">=18" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -605,6 +617,40 @@ ], "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bmp-js": { "version": "0.1.0", "resolved": "https://registry.npmmirror.com/bmp-js/-/bmp-js-0.1.0.tgz", @@ -635,6 +681,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", @@ -673,6 +743,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.0.1.tgz", @@ -730,6 +806,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", @@ -739,6 +839,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", @@ -780,6 +889,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", @@ -876,6 +994,15 @@ "node": ">=18.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", @@ -919,6 +1046,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", @@ -958,6 +1091,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -1032,6 +1171,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", @@ -1110,12 +1255,38 @@ "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", "license": "Apache-2.0" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1201,12 +1372,45 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", @@ -1216,6 +1420,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", @@ -1297,6 +1513,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/prebuild-install": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.2.tgz", + "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1310,6 +1552,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", @@ -1349,6 +1601,35 @@ "node": ">= 0.10" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -1381,12 +1662,44 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", @@ -1510,6 +1823,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", @@ -1519,6 +1877,52 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tesseract.js": { "version": "7.0.0", "resolved": "https://registry.npmmirror.com/tesseract.js/-/tesseract.js-7.0.0.tgz", @@ -1578,6 +1982,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", @@ -1631,6 +2047,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz", diff --git a/package.json b/package.json index 3838e36..46ef39c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test:agentic": "node test/e2e-agentic.mjs" }, "dependencies": { + "better-sqlite3": "^12.8.0", "dotenv": "^16.5.0", "eventsource-parser": "^3.0.1", "express": "^5.1.0", @@ -31,6 +32,7 @@ "yaml": "^2.7.1" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.2", "@types/node": "^22.15.0", "@types/uuid": "^10.0.0", diff --git a/src/config-api.ts b/src/config-api.ts index 7abfaf3..885b658 100644 --- a/src/config-api.ts +++ b/src/config-api.ts @@ -30,7 +30,7 @@ export function apiGetConfig(_req: Request, res: Response): void { }, sanitize_response: cfg.sanitizeEnabled, refusal_patterns: cfg.refusalPatterns ?? [], - logging: cfg.logging ?? { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary' }, + logging: cfg.logging ?? { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary', db_enabled: false, db_path: './logs/cursor2api.db' }, }); } diff --git a/src/config.ts b/src/config.ts index 7b34a54..f129db7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -81,6 +81,8 @@ function parseYamlConfig(defaults: AppConfig): { config: AppConfig; raw: Record< 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', + db_enabled: yaml.logging.db_enabled === true, + db_path: yaml.logging.db_path || './logs/cursor2api.db', }; } // ★ 工具处理配置 @@ -143,21 +145,29 @@ 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, persist_mode: 'summary' }; + if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary', db_enabled: false, db_path: './logs/cursor2api.db' }; 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, persist_mode: 'summary' }; + if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary', db_enabled: false, db_path: './logs/cursor2api.db' }; 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' }; + if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary', db_enabled: false, db_path: './logs/cursor2api.db' }; cfg.logging.persist_mode = process.env.LOG_PERSIST_MODE === 'full' ? 'full' : process.env.LOG_PERSIST_MODE === 'summary' ? 'summary' : 'compact'; } + if (process.env.LOG_DB_ENABLED !== undefined) { + if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary', db_enabled: false, db_path: './logs/cursor2api.db' }; + cfg.logging.db_enabled = process.env.LOG_DB_ENABLED === 'true' || process.env.LOG_DB_ENABLED === '1'; + } + if (process.env.LOG_DB_PATH) { + if (!cfg.logging) cfg.logging = { file_enabled: false, dir: './logs', max_days: 7, persist_mode: 'summary', db_enabled: false, db_path: './logs/cursor2api.db' }; + cfg.logging.db_path = process.env.LOG_DB_PATH; + } // 工具透传模式环境变量覆盖 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 c5c77ab..ad74f06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,9 +11,10 @@ import express from 'express'; import { getConfig, initConfigWatcher, stopConfigWatcher } from './config.js'; import { handleMessages, listModels, countTokens } from './handler.js'; import { handleOpenAIChatCompletions, handleOpenAIResponses } from './openai-handler.js'; -import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream, serveLogViewerLogin, apiClearLogs, serveVueApp } from './log-viewer.js'; +import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream, serveLogViewerLogin, apiClearLogs, serveVueApp, apiGetRequestsMore } from './log-viewer.js'; import { apiGetConfig, apiSaveConfig } from './config-api.js'; import { loadLogsFromFiles } from './logger.js'; +import { initDb } from './logger-db.js'; // 从 package.json 读取版本号,统一来源,避免多处硬编码 const require = createRequire(import.meta.url); @@ -68,6 +69,7 @@ app.get('/logs', logViewerAuth, serveLogViewer); // Vue3 日志 UI(无服务端鉴权,由 Vue 应用内部处理) app.get('/vuelogs', serveVueApp); app.get('/api/logs', logViewerAuth, apiGetLogs); +app.get('/api/requests/more', logViewerAuth, apiGetRequestsMore); app.get('/api/requests', logViewerAuth, apiGetRequests); app.get('/api/stats', logViewerAuth, apiGetStats); app.get('/api/payload/:requestId', logViewerAuth, apiGetPayload); @@ -151,14 +153,20 @@ app.get('/', (_req, res) => { // ==================== 启动 ==================== +// ★ 初始化 SQLite(若启用) +if (config.logging?.db_enabled) { + initDb(config.logging.db_path || './logs/cursor2api.db'); +} + // ★ 从日志文件加载历史(必须在 listen 之前) 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.persist_mode || 'summary'}) → ${config.logging.dir}` - : 'memory only'; + const logParts: string[] = []; + if (config.logging?.file_enabled) logParts.push(`file(${config.logging.persist_mode || 'summary'}) → ${config.logging.dir}`); + if (config.logging?.db_enabled) logParts.push(`sqlite → ${config.logging.db_path || './logs/cursor2api.db'}`); + const logPersist = logParts.length > 0 ? logParts.join(' + ') : 'memory only'; // Tools 配置摘要 const toolsCfg = config.tools; diff --git a/src/log-viewer.ts b/src/log-viewer.ts index a84b9ce..7447c95 100644 --- a/src/log-viewer.ts +++ b/src/log-viewer.ts @@ -8,7 +8,7 @@ import type { Request, Response } from 'express'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { getAllLogs, getRequestSummaries, getStats, getRequestPayload, subscribeToLogs, subscribeToSummaries, clearAllLogs } from './logger.js'; +import { getAllLogs, getRequestSummaries, getStats, getRequestPayload, subscribeToLogs, subscribeToSummaries, clearAllLogs, getRequestSummariesPage } from './logger.js'; // ==================== 静态文件路径 ==================== @@ -52,6 +52,16 @@ export function apiClearLogs(_req: Request, res: Response): void { res.json({ success: true, ...result }); } +/** GET /api/requests/more?limit=50&before=&status=error&keyword=foo&since= - 游标分页 + 后端过滤(仅 Vue UI 使用) */ +export function apiGetRequestsMore(req: Request, res: Response): void { + const limit = req.query.limit ? parseInt(req.query.limit as string) : 50; + const before = req.query.before ? parseInt(req.query.before as string) : undefined; + const since = req.query.since ? parseInt(req.query.since as string) : undefined; + const status = (req.query.status as string) || undefined; + const keyword = (req.query.keyword as string) || undefined; + res.json(getRequestSummariesPage({ limit, before, since, status, keyword })); +} + export function apiLogsStream(req: Request, res: Response): void { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', diff --git a/src/logger-db.ts b/src/logger-db.ts new file mode 100644 index 0000000..2a57b7e --- /dev/null +++ b/src/logger-db.ts @@ -0,0 +1,172 @@ +/** + * logger-db.ts - SQLite 持久化层 + * + * 仅在 config.logging.db_enabled = true 时使用。 + * 与 JSONL 文件方式完全并存,互不干扰。 + */ + +import Database from 'better-sqlite3'; +import { mkdirSync, existsSync } from 'fs'; +import { dirname } from 'path'; +// 使用 inline 类型避免与 logger.ts 的循环依赖 +// DbRequestSummary 和 DbRequestPayload 的最小结构定义(仅 logger-db 内部使用) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type DbRequestSummary = { requestId: string; startTime: number } & Record; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type DbRequestPayload = Record; + +let db: InstanceType | null = null; + +// ==================== 初始化 ==================== + +export function initDb(dbPath: string): void { + const dir = dirname(dbPath); + if (dir && !existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + db.exec(` + CREATE TABLE IF NOT EXISTS requests ( + request_id TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL, + summary_json TEXT NOT NULL, + payload_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_timestamp ON requests(timestamp); + `); +} + +function getDb(): InstanceType { + if (!db) throw new Error('SQLite not initialized. Call initDb() first.'); + return db; +} + +// ==================== 写入 ==================== + +export function dbInsertRequest(summary: DbRequestSummary, payload: DbRequestPayload): void { + const stmt = getDb().prepare( + 'INSERT OR REPLACE INTO requests (request_id, timestamp, summary_json, payload_json) VALUES (?, ?, ?, ?)' + ); + stmt.run( + summary.requestId, + summary.startTime, + JSON.stringify(summary), + JSON.stringify(payload) + ); +} + +// ==================== 查询 ==================== + +/** 按需加载单条 payload(Web UI 点击时调用) */ +export function dbGetPayload(requestId: string): DbRequestPayload | undefined { + const row = getDb() + .prepare('SELECT payload_json FROM requests WHERE request_id = ?') + .get(requestId) as { payload_json: string } | undefined; + if (!row?.payload_json) return undefined; + try { return JSON.parse(row.payload_json) as DbRequestPayload; } catch { return undefined; } +} + +export interface DbQueryOpts { + limit: number; + before?: number; // timestamp < before(游标翻页) + since?: number; // timestamp >= since(时间范围) + status?: string; // 精确匹配 summary.status + keyword?: string; // 模糊匹配 title/model/request_id +} + +/** 动态构建 WHERE 子句(参数化,防注入) */ +function buildWhere(opts: Omit): { where: string; params: Record } { + const conditions: string[] = []; + const params: Record = {}; + if (opts.before !== undefined) { + conditions.push('timestamp < :before'); + params.before = opts.before; + } + if (opts.since !== undefined) { + conditions.push('timestamp >= :since'); + params.since = opts.since; + } + if (opts.status) { + conditions.push("json_extract(summary_json,'$.status') = :status"); + params.status = opts.status; + } + if (opts.keyword) { + conditions.push("(request_id LIKE :kw OR json_extract(summary_json,'$.title') LIKE :kw OR json_extract(summary_json,'$.model') LIKE :kw)"); + params.kw = `%${opts.keyword}%`; + } + const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; + return { where, params }; +} + +/** + * 游标分页:返回最新的 limit 条,支持 status/keyword/since 后端过滤。 + * 结果按 timestamp 倒序(最新在前)。 + */ +export function dbGetSummaries(opts: DbQueryOpts): DbRequestSummary[] { + const { limit, ...filterOpts } = opts; + const { where, params } = buildWhere(filterOpts); + const sql = `SELECT summary_json FROM requests ${where} ORDER BY timestamp DESC LIMIT :limit`; + const rows = getDb().prepare(sql).all({ ...params, limit }) as Array<{ summary_json: string }>; + return rows.map(r => { + try { return JSON.parse(r.summary_json) as DbRequestSummary; } catch { return null; } + }).filter((s): s is DbRequestSummary => s !== null); +} + +/** 返回符合过滤条件的记录总数 */ +export function dbCountSummaries(opts: Omit = {}): number { + const { where, params } = buildWhere(opts); + const sql = `SELECT COUNT(*) as cnt FROM requests ${where}`; + const row = getDb().prepare(sql).get(params) as { cnt: number }; + return row.cnt; +} + +/** + * 返回各状态的计数(不含 status 过滤,仅受 keyword/since 影响)。 + * 用于状态筛选按钮上的计数显示,点击某状态后其他按钮数字不变。 + */ +export function dbGetStatusCounts(opts: { keyword?: string; since?: number } = {}): Record { + const { where, params } = buildWhere(opts); // 不传 status,只用 keyword/since + const sql = `SELECT json_extract(summary_json,'$.status') as status, COUNT(*) as cnt FROM requests ${where} GROUP BY status`; + const rows = getDb().prepare(sql).all(params) as Array<{ status: string; cnt: number }>; + const counts: Record = { all: 0, success: 0, error: 0, processing: 0, intercepted: 0 }; + for (const row of rows) { + if (row.status) counts[row.status] = row.cnt; + counts.all += row.cnt; + } + return counts; +} + +/** 返回数据库中全部记录总数(无过滤) */ +export function dbGetSummaryCount(): number { + const row = getDb() + .prepare('SELECT COUNT(*) as cnt FROM requests') + .get() as { cnt: number }; + return row.cnt; +} + +/** + * 启动时加载:返回 timestamp >= cutoffTimestamp 的所有 summary(不含 payload)。 + * 用于恢复内存中的请求列表。 + */ +export function dbGetSummariesSince(cutoffTimestamp: number): DbRequestSummary[] { + const rows = getDb() + .prepare('SELECT summary_json FROM requests WHERE timestamp >= ? ORDER BY timestamp ASC') + .all(cutoffTimestamp) as Array<{ summary_json: string }>; + return rows.map(r => { + try { return JSON.parse(r.summary_json) as DbRequestSummary; } catch { return null; } + }).filter((s): s is DbRequestSummary => s !== null); +} + +// ==================== 清空 ==================== + +export function dbClear(): void { + getDb().prepare('DELETE FROM requests').run(); +} + +// ==================== 状态 ==================== + +export function isDbInitialized(): boolean { + return db !== null; +} diff --git a/src/logger.ts b/src/logger.ts index a794c69..3f1887e 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -17,6 +17,7 @@ import { EventEmitter } from 'events'; import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs'; import { join, basename } from 'path'; import { getConfig } from './config.js'; +import { initDb, dbInsertRequest, dbGetPayload, dbGetSummaries, dbCountSummaries, dbGetSummaryCount, dbGetStatusCounts, dbGetSummariesSince, dbClear } from './logger-db.js'; // ==================== 类型定义 ==================== @@ -419,38 +420,83 @@ function compactPayloadForDisk(summary: RequestSummary, payload: RequestPayload) return compact; } -/** 将已完成的请求写入日志文件 */ +/** 将已完成的请求写入日志文件和/或 SQLite */ function persistRequest(summary: RequestSummary, payload: RequestPayload): void { + // ---- 原有 JSONL 文件方式(保持不变)---- 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); + 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 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', ''); @@ -460,43 +506,46 @@ export function loadLogsFromFiles(): void { 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++; + + // 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 */ } + } 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} 条历史记录`); } - } - - // 裁剪到 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); @@ -522,7 +571,13 @@ export function clearAllLogs(): { cleared: number } { } } catch { /* ignore */ } } - + + // 清空 SQLite + const cfg = getConfig(); + if (cfg.logging?.db_enabled) { + try { dbClear(); } catch { /* ignore */ } + } + return { cleared: count }; } @@ -622,7 +677,81 @@ export function getRequestSummaries(limit?: number): RequestSummary[] { /** 获取请求的完整 payload 数据 */ export function getRequestPayload(requestId: string): RequestPayload | undefined { - return requestPayloads.get(requestId); + // 先查内存 + 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 } { + 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 = { all: allUnfiltered.length, success: 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 { diff --git a/src/types.ts b/src/types.ts index 77610e0..38d62b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -139,6 +139,8 @@ export interface AppConfig { dir: string; // 日志文件存储目录 max_days: number; // 日志保留天数 persist_mode: 'compact' | 'full' | 'summary'; // 落盘模式: compact=精简, full=完整, summary=仅问答摘要 + db_enabled: boolean; // 是否启用 SQLite 存储 + db_path: string; // SQLite 文件路径,默认 './logs/cursor2api.db' }; tools?: { schemaMode: 'compact' | 'full' | 'names_only'; // Schema 呈现模式 diff --git a/test/migrate-jsonl-to-sqlite.mjs b/test/migrate-jsonl-to-sqlite.mjs new file mode 100644 index 0000000..78193c5 --- /dev/null +++ b/test/migrate-jsonl-to-sqlite.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +/** + * test/migrate-jsonl-to-sqlite.mjs + * + * 将现有 JSONL 日志文件迁移到 SQLite 数据库。 + * 运行方式:node test/migrate-jsonl-to-sqlite.mjs [--db ./logs/cursor2api.db] [--dir ./logs] [--dry-run] + * + * 选项: + * --db SQLite 文件路径(默认 ./logs/cursor2api.db) + * --dir JSONL 日志目录(默认 ./logs) + * --dry-run 只统计不写入 + * --clear 写入前清空数据库已有数据 + */ + +import Database from 'better-sqlite3'; +import { readFileSync, readdirSync, existsSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; + +// ==================== 参数解析 ==================== + +const args = process.argv.slice(2); +function getArg(name) { + const idx = args.indexOf(name); + return idx >= 0 ? args[idx + 1] : null; +} +const DB_PATH = getArg('--db') || './logs/cursor2api.db'; +const LOG_DIR = getArg('--dir') || './logs'; +const DRY_RUN = args.includes('--dry-run'); +const CLEAR = args.includes('--clear'); + +console.log('=== JSONL → SQLite 迁移工具 ==='); +console.log(`日志目录: ${LOG_DIR}`); +console.log(`SQLite: ${DB_PATH}`); +console.log(`模式: ${DRY_RUN ? 'dry-run(只统计)' : '写入'}`); +if (CLEAR && !DRY_RUN) console.log('清空模式: 是'); +console.log(); + +// ==================== 检查日志目录 ==================== + +if (!existsSync(LOG_DIR)) { + console.error(`日志目录不存在: ${LOG_DIR}`); + process.exit(1); +} + +const jsonlFiles = readdirSync(LOG_DIR) + .filter(f => f.startsWith('cursor2api-') && f.endsWith('.jsonl')) + .sort(); + +if (jsonlFiles.length === 0) { + console.log('未找到 JSONL 日志文件,退出。'); + process.exit(0); +} + +console.log(`找到 ${jsonlFiles.length} 个 JSONL 文件:`); +for (const f of jsonlFiles) { + const content = readFileSync(join(LOG_DIR, f), 'utf-8'); + const lines = content.split('\n').filter(Boolean); + console.log(` ${f} (${lines.length} 行)`); +} +console.log(); + +if (DRY_RUN) { + let total = 0; + for (const f of jsonlFiles) { + const lines = readFileSync(join(LOG_DIR, f), 'utf-8').split('\n').filter(Boolean); + total += lines.length; + } + console.log(`[dry-run] 共 ${total} 条记录,无写入操作。`); + process.exit(0); +} + +// ==================== 初始化 SQLite ==================== + +const dbDir = dirname(DB_PATH); +if (dbDir && !existsSync(dbDir)) mkdirSync(dbDir, { recursive: true }); + +const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); +db.pragma('synchronous = NORMAL'); +db.exec(` + CREATE TABLE IF NOT EXISTS requests ( + request_id TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL, + summary_json TEXT NOT NULL, + payload_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_timestamp ON requests(timestamp); +`); + +if (CLEAR) { + const { changes } = db.prepare('DELETE FROM requests').run(); + console.log(`已清空数据库(删除 ${changes} 条)`); +} + +const existingCount = db.prepare('SELECT COUNT(*) as cnt FROM requests').get().cnt; +console.log(`数据库现有记录: ${existingCount} 条`); +console.log(); + +// ==================== 迁移 ==================== + +const insert = db.prepare( + 'INSERT OR IGNORE INTO requests (request_id, timestamp, summary_json, payload_json) VALUES (?, ?, ?, ?)' +); + +const migrate = db.transaction((lines) => { + let inserted = 0, skipped = 0, malformed = 0; + for (const line of lines) { + try { + const record = JSON.parse(line); + const summary = record.summary; + if (!summary?.requestId) { malformed++; continue; } + const result = insert.run( + summary.requestId, + summary.startTime || record.timestamp || Date.now(), + JSON.stringify(summary), + record.payload ? JSON.stringify(record.payload) : null + ); + if (result.changes > 0) inserted++; + else skipped++; + } catch { + malformed++; + } + } + return { inserted, skipped, malformed }; +}); + +let totalInserted = 0, totalSkipped = 0, totalMalformed = 0; + +for (const f of jsonlFiles) { + const content = readFileSync(join(LOG_DIR, f), 'utf-8'); + const lines = content.split('\n').filter(Boolean); + process.stdout.write(`迁移 ${f} (${lines.length} 行)... `); + const { inserted, skipped, malformed } = migrate(lines); + console.log(`插入 ${inserted},跳过(重复) ${skipped},格式错误 ${malformed}`); + totalInserted += inserted; + totalSkipped += skipped; + totalMalformed += malformed; +} + +// ==================== 结果统计 ==================== + +const finalCount = db.prepare('SELECT COUNT(*) as cnt FROM requests').get().cnt; + +console.log(); +console.log('=== 迁移完成 ==='); +console.log(`插入新记录: ${totalInserted}`); +console.log(`跳过(重复): ${totalSkipped}`); +console.log(`格式错误: ${totalMalformed}`); +console.log(`数据库总计: ${finalCount} 条`); + +// 验证:读取最新 5 条 +console.log(); +console.log('=== 验证:最新 5 条记录 ==='); +const rows = db.prepare('SELECT request_id, timestamp, summary_json FROM requests ORDER BY timestamp DESC LIMIT 5').all(); +for (const row of rows) { + const s = JSON.parse(row.summary_json); + const date = new Date(row.timestamp).toISOString(); + console.log(` [${date}] ${row.request_id} | ${s.model || '?'} | ${s.status || '?'} | ${s.title ? s.title.slice(0, 40) : '(无标题)'}`); +} + +db.close(); +console.log(); +console.log('完成。'); diff --git a/test/unit-logger-db.mjs b/test/unit-logger-db.mjs new file mode 100644 index 0000000..7c81522 --- /dev/null +++ b/test/unit-logger-db.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env node +/** + * test/unit-logger-db.mjs + * + * 单元测试:logger-db.ts 的 SQLite 接口功能验证 + * 运行方式:node test/unit-logger-db.mjs + * + * 测试内容: + * 1. initDb - 初始化创建表和索引 + * 2. dbInsertRequest - 写入记录 + * 3. dbGetPayload - 按需读取 payload + * 4. dbGetSummaries - 游标分页查询 + * 5. dbGetSummaryCount - 总数统计 + * 6. dbGetSummariesSince - 按时间范围加载(启动恢复) + * 7. dbClear - 清空 + * 8. 分页边界:before 游标正确性 + * 9. INSERT OR REPLACE 幂等性 + */ + +import Database from 'better-sqlite3'; +import { existsSync, unlinkSync, mkdirSync } from 'fs'; +import { dirname } from 'path'; + +// ==================== 测试框架 ==================== + +let passed = 0, failed = 0; +const errors = []; + +function assert(condition, msg) { + if (condition) { + passed++; + console.log(` ✓ ${msg}`); + } else { + failed++; + const err = ` ✗ ${msg}`; + errors.push(err); + console.error(err); + } +} + +function assertEq(actual, expected, msg) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (!ok) { + console.error(` actual: ${JSON.stringify(actual)}`); + console.error(` expected: ${JSON.stringify(expected)}`); + } + assert(ok, msg); +} + +// ==================== 内联实现(与 src/logger-db.ts 保持同步)==================== +// 使用相同逻辑直接操作 better-sqlite3,不依赖 dist/ + +const TEST_DB_PATH = '/tmp/cursor2api-test.db'; + +// 清理旧测试数据库 +if (existsSync(TEST_DB_PATH)) unlinkSync(TEST_DB_PATH); + +let db; + +function initDb(dbPath) { + const dir = dirname(dbPath); + if (dir && dir !== '.' && !existsSync(dir)) mkdirSync(dir, { recursive: true }); + db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + db.exec(` + CREATE TABLE IF NOT EXISTS requests ( + request_id TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL, + summary_json TEXT NOT NULL, + payload_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_timestamp ON requests(timestamp); + `); +} + +function dbInsertRequest(summary, payload) { + db.prepare( + 'INSERT OR REPLACE INTO requests (request_id, timestamp, summary_json, payload_json) VALUES (?, ?, ?, ?)' + ).run(summary.requestId, summary.startTime, JSON.stringify(summary), JSON.stringify(payload)); +} + +function dbGetPayload(requestId) { + const row = db.prepare('SELECT payload_json FROM requests WHERE request_id = ?').get(requestId); + if (!row?.payload_json) return undefined; + try { return JSON.parse(row.payload_json); } catch { return undefined; } +} + +function dbGetSummaries({ limit, before }) { + let rows; + if (before !== undefined) { + rows = db.prepare('SELECT summary_json FROM requests WHERE timestamp < ? ORDER BY timestamp DESC LIMIT ?').all(before, limit); + } else { + rows = db.prepare('SELECT summary_json FROM requests ORDER BY timestamp DESC LIMIT ?').all(limit); + } + return rows.map(r => { try { return JSON.parse(r.summary_json); } catch { return null; } }).filter(Boolean); +} + +function dbGetSummaryCount() { + return db.prepare('SELECT COUNT(*) as cnt FROM requests').get().cnt; +} + +function dbGetSummariesSince(cutoff) { + const rows = db.prepare('SELECT summary_json FROM requests WHERE timestamp >= ? ORDER BY timestamp ASC').all(cutoff); + return rows.map(r => { try { return JSON.parse(r.summary_json); } catch { return null; } }).filter(Boolean); +} + +function dbClear() { + db.prepare('DELETE FROM requests').run(); +} + +// ==================== 测试数据 ==================== + +function makeSummary(id, startTime, extra = {}) { + return { + requestId: id, + startTime, + endTime: startTime + 1000, + method: 'POST', + path: '/v1/messages', + model: 'claude-sonnet-4-6', + stream: true, + apiFormat: 'anthropic', + hasTools: false, + toolCount: 0, + messageCount: 3, + status: 'success', + responseChars: 500, + retryCount: 0, + continuationCount: 0, + toolCallsDetected: 0, + thinkingChars: 0, + systemPromptLength: 100, + phaseTimings: [], + title: `测试请求 ${id}`, + ...extra, + }; +} + +function makePayload(id) { + return { + question: `用户问题 ${id}`, + answer: `模型回答 ${id}`, + answerType: 'text', + }; +} + +// 时间基准(各记录间隔 1 秒) +const BASE_TS = Date.now() - 10000; +const records = [ + { summary: makeSummary('req-001', BASE_TS + 1000), payload: makePayload('req-001') }, + { summary: makeSummary('req-002', BASE_TS + 2000), payload: makePayload('req-002') }, + { summary: makeSummary('req-003', BASE_TS + 3000), payload: makePayload('req-003') }, + { summary: makeSummary('req-004', BASE_TS + 4000, { status: 'error', error: '超时' }), payload: makePayload('req-004') }, + { summary: makeSummary('req-005', BASE_TS + 5000), payload: makePayload('req-005') }, +]; + +// ==================== 开始测试 ==================== + +console.log('=== unit-logger-db: SQLite 接口功能测试 ===\n'); + +// --- 1. initDb --- +console.log('【1】initDb'); +try { + initDb(TEST_DB_PATH); + assert(existsSync(TEST_DB_PATH), '数据库文件已创建'); + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name); + assert(tables.includes('requests'), '表 requests 已创建'); + const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all().map(r => r.name); + assert(indexes.includes('idx_timestamp'), '索引 idx_timestamp 已创建'); +} catch (e) { + assert(false, `initDb 抛出异常: ${e.message}`); +} + +// --- 2. dbInsertRequest --- +console.log('\n【2】dbInsertRequest'); +for (const { summary, payload } of records) { + dbInsertRequest(summary, payload); +} +assertEq(dbGetSummaryCount(), 5, '插入 5 条后总数为 5'); + +// --- 3. dbGetPayload --- +console.log('\n【3】dbGetPayload'); +const p2 = dbGetPayload('req-002'); +assert(p2 !== undefined, 'req-002 payload 可读取'); +assertEq(p2.question, '用户问题 req-002', 'payload.question 正确'); +assertEq(p2.answer, '模型回答 req-002', 'payload.answer 正确'); +assert(dbGetPayload('req-999') === undefined, '不存在的 requestId 返回 undefined'); + +// --- 4. dbGetSummaries 无游标(最新在前)--- +console.log('\n【4】dbGetSummaries(无游标)'); +const all = dbGetSummaries({ limit: 10 }); +assertEq(all.length, 5, '返回全部 5 条'); +assertEq(all[0].requestId, 'req-005', '第一条是最新的 req-005'); +assertEq(all[4].requestId, 'req-001', '最后一条是最旧的 req-001'); + +// --- 5. dbGetSummaries limit --- +console.log('\n【5】dbGetSummaries(limit=3)'); +const top3 = dbGetSummaries({ limit: 3 }); +assertEq(top3.length, 3, '返回 3 条'); +assertEq(top3[0].requestId, 'req-005', '第一条是 req-005'); +assertEq(top3[2].requestId, 'req-003', '第三条是 req-003'); + +// --- 6. dbGetSummaries before 游标翻页 --- +console.log('\n【6】dbGetSummaries(游标分页)'); +// 第一页:最新 3 条(req-005, req-004, req-003) +const page1 = dbGetSummaries({ limit: 3 }); +assertEq(page1.length, 3, '第一页 3 条'); +assertEq(page1[0].requestId, 'req-005', '第一页第一条 req-005'); + +// 第二页:before = page1 最后一条的 timestamp +const beforeTs = page1[page1.length - 1].startTime; +const page2 = dbGetSummaries({ limit: 3, before: beforeTs }); +assertEq(page2.length, 2, '第二页 2 条(剩余 req-002, req-001)'); +assertEq(page2[0].requestId, 'req-002', '第二页第一条 req-002'); +assertEq(page2[1].requestId, 'req-001', '第二页第二条 req-001'); + +// --- 7. dbGetSummaryCount --- +console.log('\n【7】dbGetSummaryCount'); +assertEq(dbGetSummaryCount(), 5, '总数为 5'); + +// --- 8. dbGetSummariesSince(启动时加载)--- +console.log('\n【8】dbGetSummariesSince'); +// 只取 timestamp >= BASE_TS + 3000 的记录(req-003, req-004, req-005) +const since = dbGetSummariesSince(BASE_TS + 3000); +assertEq(since.length, 3, 'since 返回 3 条'); +assertEq(since[0].requestId, 'req-003', '第一条 req-003(ASC 顺序)'); +assertEq(since[2].requestId, 'req-005', '最后一条 req-005'); + +// cutoff 比所有记录都新 → 返回空 +const sinceEmpty = dbGetSummariesSince(Date.now() + 99999); +assertEq(sinceEmpty.length, 0, '未来 cutoff 返回空数组'); + +// --- 9. INSERT OR REPLACE 幂等性 --- +console.log('\n【9】INSERT OR REPLACE 幂等性'); +const updatedSummary = { ...records[0].summary, status: 'error', title: '已更新' }; +dbInsertRequest(updatedSummary, records[0].payload); +assertEq(dbGetSummaryCount(), 5, '重复插入后总数不变(仍 5 条)'); +const allAfter = dbGetSummaries({ limit: 10 }); +const updated = allAfter.find(s => s.requestId === 'req-001'); +assertEq(updated?.title, '已更新', 'REPLACE 更新了 summary 内容'); + +// --- 10. dbClear --- +console.log('\n【10】dbClear'); +dbClear(); +assertEq(dbGetSummaryCount(), 0, '清空后总数为 0'); +const afterClear = dbGetSummaries({ limit: 10 }); +assertEq(afterClear.length, 0, '清空后查询返回空数组'); +assert(dbGetPayload('req-001') === undefined, '清空后 payload 也不可读取'); + +// ==================== 结果 ==================== + +console.log(`\n${'='.repeat(40)}`); +console.log(`测试结果: ${passed} 通过 / ${failed} 失败`); +if (errors.length > 0) { + console.error('\n失败项:'); + for (const e of errors) console.error(e); +} + +// 清理 +db.close(); +try { unlinkSync(TEST_DB_PATH); } catch { /* ignore */ } + +process.exit(failed > 0 ? 1 : 0); diff --git a/vue-ui/README.md b/vue-ui/README.md index 3e01d96..772580b 100644 --- a/vue-ui/README.md +++ b/vue-ui/README.md @@ -23,7 +23,7 @@ vue-ui/ │ │ ├── LoginPage.vue # 登录页 │ │ ├── AppHeader.vue # 顶部导航(含配置按钮) │ │ ├── LogList.vue # 日志列表 -│ │ ├── RequestList.vue # 请求列表 +│ │ ├── RequestList.vue # 请求列表(支持后端过滤和分页) │ │ ├── DetailPanel.vue # 请求详情面板 │ │ ├── PayloadView.vue # Payload 查看 │ │ ├── PhaseTimeline.vue # 阶段时间线 @@ -41,76 +41,29 @@ vue-ui/ └── vite.config.ts ``` -## 开发 +## 本地开发 ```bash -# 进入前端目录 -cd vue-ui - -# 安装依赖 -npm install - -# 启动开发服务器(默认 http://localhost:5173) -# 会自动将 /api 请求代理到 http://localhost:3010 +# 同时启动后端(项目根目录) npm run dev + +# 启动前端开发服务器(vue-ui 目录,默认 http://localhost:5173) +cd vue-ui && npm install && npm run dev ``` -开发时需同时启动后端服务: - -```bash -# 在项目根目录 -npm run dev -``` +前端开发服务器会自动将 `/api` 请求代理到 `http://localhost:3010`。 ## 构建 ```bash -cd vue-ui -npm run build +cd vue-ui && npm run build ``` 产物输出到项目根目录的 `public/vue/`,后端通过 `/vuelogs` 路由提供服务。 > **重要**:Docker 镜像打包前必须先执行此构建步骤,否则容器内将缺少前端静态资源。 -## Docker 部署注意事项 - -### 1. 先构建前端再构建镜像 - -Dockerfile 不会自动构建 Vue UI,需要先在本地生成产物: - -```bash -# 第一步:构建前端(在 vue-ui 目录) -cd vue-ui && npm install && npm run build && cd .. - -# 第二步:构建并启动容器 -docker compose up -d --build -``` - -### 2. config.yaml 不能挂载为只读 - -配置抽屉支持通过 Web UI 实时修改并写回 `config.yaml`,因此挂载时**不能**加 `:ro` 只读标志: - -```yaml -# ✅ 正确 -volumes: - - ./config.yaml:/app/config.yaml - -# ❌ 错误(UI 保存配置时会报 EROFS: read-only file system) -volumes: - - ./config.yaml:/app/config.yaml:ro -``` - -### 3. 首次部署前准备 config.yaml - -挂载前宿主机上必须已存在 `config.yaml`,否则 Docker 会将其创建为目录: - -```bash -cp config.yaml.example config.yaml -# 按需编辑 config.yaml -``` - -### 4. 完整部署流程 +## Docker 部署 ```bash # 1. 准备配置文件 @@ -119,16 +72,24 @@ cp config.yaml.example config.yaml # 2. 构建前端 cd vue-ui && npm install && npm run build && cd .. -# 3. 启动服务 +# 3. 构建并启动容器 docker compose up -d --build # 4. 访问日志 UI open http://localhost:3010/vuelogs ``` +**注意事项:** + +- `config.yaml` 挂载时**不能**加 `:ro` 只读标志,否则配置抽屉无法保存 +- 如遇到 `EACCES: permission denied` 写入权限错误,需设置文件权限: + ```bash + chmod 666 config.yaml + ``` + ## 配置抽屉 -点击顶部右侧的 **⚙ 配置** 按钮可打开配置面板,支持修改以下热重载配置项: +点击顶部右侧的 **⚙ 配置** 按钮可打开配置面板。大部分配置保存后通过 fs.watch 热重载,下一次请求即生效,无需重启。 | 分组 | 字段 | 说明 | |------|------|------| @@ -136,15 +97,16 @@ open http://localhost:3010/vuelogs | 基础 | `timeout` | 请求超时(秒) | | 基础 | `max_auto_continue` | 自动续写次数 | | 基础 | `max_history_messages` | 历史消息条数上限(建议改用 max_history_tokens) | -| 基础 | `max_history_tokens` | 历史消息 token 数上限(推荐),代码自动补偿 Cursor 后端开销(1,300 基础 + 工具 tokenizer 差异,动态计算),参考值 130000~170000,默认 150000 | +| 基础 | `max_history_tokens` | 历史消息 token 数上限(推荐),默认 150000,参考值 130000~170000 | | 功能 | `thinking.enabled` | Thinking 模式(跟随客户端/强制关闭/强制开启) | | 功能 | `sanitize_response` | 响应内容清洗 | | 历史压缩 | `compression.*` | 压缩开关、级别、保留条数等 | | 工具处理 | `tools.*` | Schema 模式、透传/禁用 | -| 日志持久化 | `logging.*` | 文件持久化、目录、落盘模式 | +| 日志持久化 | `logging.db_enabled` / `logging.db_path` | SQLite 持久化(推荐) | +| 日志持久化 | `logging.file_enabled` / `logging.dir` / `logging.persist_mode` | JSONL 文件持久化 | | 高级 | `refusal_patterns` | 自定义拒绝检测正则 | -保存后配置立即写入 `config.yaml`,fs.watch 热重载下一次请求即生效,无需重启服务。 +> **注意**:`logging.*` 属于启动时初始化参数,修改后**需重启服务**才能生效;其余配置热重载,下一次请求即生效。 ## 与原有日志页面的关系 diff --git a/vue-ui/src/api.ts b/vue-ui/src/api.ts index 6f74495..290d17e 100644 --- a/vue-ui/src/api.ts +++ b/vue-ui/src/api.ts @@ -65,6 +65,30 @@ export async function saveConfig(cfg: Partial): Promise; } +export interface RequestsPage { + summaries: RequestSummary[]; + hasMore: boolean; + total: number; + statusCounts: Record; +} + +export interface RequestsFilter { + limit?: number; + before?: number; + status?: string; + keyword?: string; + since?: number; +} + +export function fetchMoreRequests(filter: RequestsFilter = {}): Promise { + const q = new URLSearchParams({ limit: String(filter.limit ?? 50) }); + if (filter.before !== undefined) q.set('before', String(filter.before)); + if (filter.since !== undefined) q.set('since', String(filter.since)); + if (filter.status) q.set('status', filter.status); + if (filter.keyword) q.set('keyword', filter.keyword); + return apiFetch(`/api/requests/more?${q.toString()}`); +} + export function createSSEConnection(onMessage: (event: string, data: unknown) => void): EventSource { const token = localStorage.getItem('cursor2api_token'); const url = token ? `/api/logs/stream?token=${encodeURIComponent(token)}` : '/api/logs/stream'; diff --git a/vue-ui/src/components/ConfigDrawer.vue b/vue-ui/src/components/ConfigDrawer.vue index d8002c5..bff9616 100644 --- a/vue-ui/src/components/ConfigDrawer.vue +++ b/vue-ui/src/components/ConfigDrawer.vue @@ -94,7 +94,16 @@ - +
⚠️ 此分组所有配置修改后需重启服务才能生效
+ + + + + @@ -134,16 +140,7 @@ const statusTabs = [ { value: 'intercepted' as const, label: '中断' }, ]; -const counts = computed(() => { - const base = logsStore.reqs; - return { - all: base.length, - success: base.filter(r => r.status === 'success').length, - error: base.filter(r => r.status === 'error').length, - processing: base.filter(r => r.status === 'processing').length, - intercepted: base.filter(r => r.status === 'intercepted').length, - }; -}); +const counts = computed(() => logsStore.statusCounts); function fmtDate(ts: number): string { const d = new Date(ts); @@ -344,4 +341,8 @@ function selectReq(id: string) { @keyframes prog { 0%,100%{opacity:.4} 50%{opacity:1} } .rerr { color: var(--red); margin-top: 3px; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.load-more { padding: 8px; text-align: center; } +.lm-btn { width: 100%; padding: 6px 0; font-size: 12px; color: var(--text-muted); background: var(--bg-2); border: 1px solid var(--border-faint); border-radius: 4px; cursor: pointer; } +.lm-btn:hover:not(:disabled) { background: var(--bg-3); color: var(--text); } +.lm-btn:disabled { opacity: 0.5; cursor: default; } diff --git a/vue-ui/src/stores/logs.ts b/vue-ui/src/stores/logs.ts index 796cb0a..36cd4eb 100644 --- a/vue-ui/src/stores/logs.ts +++ b/vue-ui/src/stores/logs.ts @@ -1,7 +1,8 @@ import { defineStore } from 'pinia'; -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; import type { LogEntry, RequestSummary, Payload } from '../types'; -import { fetchRequests, fetchLogs, fetchPayload, clearLogs } from '../api'; +import { fetchRequests, fetchLogs, fetchPayload, clearLogs, fetchMoreRequests } from '../api'; +import type { RequestsFilter } from '../api'; export const useLogsStore = defineStore('logs', () => { const reqs = ref([]); @@ -9,6 +10,11 @@ export const useLogsStore = defineStore('logs', () => { const globalLogs = ref([]); // 全局实时日志流(未选中时显示) const curRequestId = ref(null); const payload = ref(null); + const hasMore = ref(false); + const loadingMore = ref(false); + const total = ref(0); + const statusCounts = ref>({ all: 0, success: 0, error: 0, processing: 0, intercepted: 0 }); + const search = ref(''); const statusFilter = ref<'all' | 'success' | 'error' | 'processing' | 'intercepted'>('all'); const timeFilter = ref<'all' | 'today' | '2d' | '7d' | '30d'>('all'); @@ -23,32 +29,43 @@ export const useLogsStore = defineStore('logs', () => { return now - (map[timeFilter.value] ?? 0) * 86400000; } - const filteredReqs = computed(() => { - let list = reqs.value; - if (search.value) { - const q = search.value.toLowerCase(); - list = list.filter(r => - r.requestId.toLowerCase().includes(q) || - r.model.toLowerCase().includes(q) || - (r.title ?? '').toLowerCase().includes(q) || - (r.error ?? '').toLowerCase().includes(q) - ); - } - if (statusFilter.value !== 'all') { - list = list.filter(r => r.status === statusFilter.value); - } + /** 构建当前过滤条件(可附加额外参数) */ + function buildFilter(extra: Partial = {}): RequestsFilter { + const filter: RequestsFilter = { limit: 50, ...extra }; + if (statusFilter.value !== 'all') filter.status = statusFilter.value; + if (search.value.trim()) filter.keyword = search.value.trim(); const cutoff = getTimeCutoff(); - if (cutoff > 0) list = list.filter(r => r.startTime >= cutoff); - return list; - }); + if (cutoff > 0) filter.since = cutoff; + return filter; + } + + // 后端已过滤,filteredReqs 直接透传 reqs(无需前端重复过滤) + const filteredReqs = computed(() => reqs.value); // 当前显示的日志:选中请求时显示该请求日志,否则显示全局流最后 200 条 const displayLogs = computed(() => curRequestId.value ? curLogs.value : globalLogs.value.slice(-200) ); + /** 重置列表并从第一页重新加载(过滤条件变化时调用) */ + async function resetAndLoad() { + reqs.value = []; + hasMore.value = false; + total.value = 0; + await loadRequests(); + } + async function loadRequests() { - try { reqs.value = await fetchRequests(100); } catch { /* ignore */ } + try { + const page = await fetchMoreRequests(buildFilter()); + reqs.value = page.summaries; + hasMore.value = page.hasMore; + total.value = page.total; + if (page.statusCounts) statusCounts.value = page.statusCounts; + } catch { + // 降级到原有接口 + try { reqs.value = await fetchRequests(100); } catch { /* ignore */ } + } // 加载历史全局日志(最近 200 条),填充实时流初始内容 try { const logs = await fetchLogs(); @@ -56,21 +73,44 @@ export const useLogsStore = defineStore('logs', () => { } catch { /* ignore */ } } + async function loadMoreRequests() { + if (loadingMore.value || !hasMore.value) return; + loadingMore.value = true; + try { + const last = reqs.value[reqs.value.length - 1]; + const page = await fetchMoreRequests(buildFilter({ before: last?.startTime })); + reqs.value.push(...page.summaries); + hasMore.value = page.hasMore; + total.value = page.total; + } catch { /* ignore */ } finally { + loadingMore.value = false; + } + } + + // 状态/时间过滤:点击立即触发 + watch([statusFilter, timeFilter], () => { + resetAndLoad(); + }); + + // 搜索框:400ms 防抖 + let debounceTimer: ReturnType | null = null; + watch(search, () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + resetAndLoad(); + }, 400); + }); + async function selectRequest(id: string) { curRequestId.value = id; - // 保留旧 curLogs/payload 直到新数据就绪,避免中间空态闪烁 - try { - const [l, p] = await Promise.all([fetchLogs({ requestId: id }), fetchPayload(id)]); - if (curRequestId.value === id) { - curLogs.value = l; - payload.value = p; - } - } catch { - if (curRequestId.value === id) { - curLogs.value = []; - payload.value = null; - } - } + payload.value = null; + curLogs.value = []; + const [l, p] = await Promise.all([ + fetchLogs({ requestId: id }), + fetchPayload(id), + ]); + curLogs.value = l; + payload.value = p; } function deselect() { @@ -80,7 +120,6 @@ export const useLogsStore = defineStore('logs', () => { } function addLog(entry: LogEntry) { - // 全局流 globalLogs.value.push(entry); if (globalLogs.value.length > 2000) globalLogs.value = globalLogs.value.slice(-1500); // 当前请求流 @@ -92,8 +131,18 @@ export const useLogsStore = defineStore('logs', () => { function upsertRequest(summary: RequestSummary) { const idx = reqs.value.findIndex(r => r.requestId === summary.requestId); if (idx >= 0) { + // 状态变更:更新 statusCounts + const oldStatus = reqs.value[idx].status; + if (oldStatus !== summary.status) { + if (oldStatus && statusCounts.value[oldStatus]) statusCounts.value[oldStatus]--; + if (summary.status) statusCounts.value[summary.status] = (statusCounts.value[summary.status] ?? 0) + 1; + } reqs.value[idx] = summary; } else { + // 新请求:递增计数 + statusCounts.value.all = (statusCounts.value.all ?? 0) + 1; + if (summary.status) statusCounts.value[summary.status] = (statusCounts.value[summary.status] ?? 0) + 1; + total.value++; reqs.value.unshift(summary); } } @@ -119,6 +168,7 @@ export const useLogsStore = defineStore('logs', () => { return { reqs, curLogs, globalLogs, displayLogs, curRequestId, payload, search, statusFilter, timeFilter, filteredReqs, - loadRequests, selectRequest, deselect, addLog, upsertRequest, clear, resetState, + hasMore, loadingMore, total, statusCounts, + loadRequests, loadMoreRequests, selectRequest, deselect, addLog, upsertRequest, clear, resetState, }; }); diff --git a/vue-ui/src/types.ts b/vue-ui/src/types.ts index 195fd14..b121ff3 100644 --- a/vue-ui/src/types.ts +++ b/vue-ui/src/types.ts @@ -74,7 +74,7 @@ export interface HotConfig { tools: { schema_mode: 'compact' | 'full' | 'names_only'; description_max_length: number; passthrough?: boolean; disabled?: boolean }; sanitize_response: boolean; refusal_patterns: string[]; - logging: { file_enabled: boolean; dir: string; max_days: number; persist_mode: 'compact' | 'full' | 'summary' }; + logging: { file_enabled: boolean; dir: string; max_days: number; persist_mode: 'compact' | 'full' | 'summary'; db_enabled: boolean; db_path: string }; } export interface SaveConfigResult {