feat: 新增 SQLite 持久化支持 + Vue UI 后端过滤与分页优化

- 新增 src/logger-db.ts:SQLite 封装层(WAL 模式,支持写入/分页/状态计数/按需 payload 查询)
- logger.ts:双写 SQLite+JSONL,启动时 db_enabled 模式跳过 JSONL 读取避免 OOM,新增游标分页和后端过滤函数
- config.ts/config-api.ts:新增 db_enabled/db_path 配置字段及 LOG_DB_ENABLED/LOG_DB_PATH 环境变量
- log-viewer.ts/index.ts:新增 /api/requests/more 支持 status/keyword/since 后端过滤
- Vue UI:搜索框 400ms 防抖,状态/时间筛选立即触发后端查询,statusCounts 不受状态筛选影响,SSE 实时推送时增量更新计数
- 新增迁移工具 test/migrate-jsonl-to-sqlite.mjs 和单元测试 test/unit-logger-db.mjs
- 完善 README.md、config.yaml.example、docker-compose.yml、vue-ui/README.md 文档
This commit is contained in:
huangzhenting
2026-03-22 21:10:26 +08:00
parent 9a69e66a7e
commit 1bc91cac24
20 changed files with 1440 additions and 184 deletions

View File

@@ -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 后端开销) |

View File

@@ -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 问题,支持重启后历史查询和分页)
# 优势:启动时仅加载 summarypayload 按需查询,彻底避免 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"

View File

@@ -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==

422
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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' },
});
}

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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=<ts>&status=error&keyword=foo&since=<ts> - 游标分页 + 后端过滤(仅 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',

172
src/logger-db.ts Normal file
View File

@@ -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<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DbRequestPayload = Record<string, any>;
let db: InstanceType<typeof Database> | 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<typeof Database> {
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)
);
}
// ==================== 查询 ====================
/** 按需加载单条 payloadWeb 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<DbQueryOpts, 'limit'>): { where: string; params: Record<string, unknown> } {
const conditions: string[] = [];
const params: Record<string, unknown> = {};
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<DbQueryOpts, 'limit' | 'before'> = {}): 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<string, number> {
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<string, number> = { 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;
}

View File

@@ -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<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, 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 {

View File

@@ -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 呈现模式

View File

@@ -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 <path> SQLite 文件路径(默认 ./logs/cursor2api.db
* --dir <path> 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('完成。');

264
test/unit-logger-db.mjs Normal file
View File

@@ -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】dbGetSummarieslimit=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-003ASC 顺序)');
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);

View File

@@ -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.*` 属于启动时初始化参数,修改后**需重启服务**才能生效;其余配置热重载下一次请求即生效。
## 与原有日志页面的关系

View File

@@ -65,6 +65,30 @@ export async function saveConfig(cfg: Partial<HotConfig>): Promise<SaveConfigRes
return res.json() as Promise<SaveConfigResult>;
}
export interface RequestsPage {
summaries: RequestSummary[];
hasMore: boolean;
total: number;
statusCounts: Record<string, number>;
}
export interface RequestsFilter {
limit?: number;
before?: number;
status?: string;
keyword?: string;
since?: number;
}
export function fetchMoreRequests(filter: RequestsFilter = {}): Promise<RequestsPage> {
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<RequestsPage>(`/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';

View File

@@ -94,7 +94,16 @@
<!-- 日志 -->
<Group title="日志持久化logging">
<Field label="logging.file_enabled" desc="开启后日志会写入文件,重启后自动加载历史记录。默认关闭">
<div class="restart-notice"> 此分组所有配置修改后需重启服务才能生效</div>
<Field label="logging.db_enabled" desc="SQLite 持久化推荐。启动时仅加载摘要payload 按需查询,彻底避免大文件 OOMVue UI 支持重启后翻页查看完整历史">
<Toggle v-model="draft.logging.db_enabled" />
</Field>
<template v-if="draft.logging.db_enabled">
<Field label="logging.db_path" desc="SQLite 文件路径,默认 ./logs/cursor2api.db。Docker 部署请确保 logs 目录已挂载">
<input v-model="draft.logging.db_path" type="text" class="inp inp-wide" />
</Field>
</template>
<Field label="logging.file_enabled" desc="JSONL 文件持久化。日志量大时(>100MB/天)建议改用 SQLite 方式">
<Toggle v-model="draft.logging.file_enabled" />
</Field>
<template v-if="draft.logging.file_enabled">
@@ -104,7 +113,7 @@
<Field label="logging.max_days" desc="超出天数的日志文件自动清理,默认 7 天">
<input v-model.number="draft.logging.max_days" type="number" min="1" class="inp" />
</Field>
<Field label="logging.persist_mode" desc="summary=仅保留问答摘要与少量元数据默认compact=精简调试信息保留更多排障细节full=完整持久化">
<Field label="logging.persist_mode" desc="summary=仅保留问答摘要与少量元数据默认compact=精简调试信息保留更多排障细节full=完整持久化(体积最大,慎用)">
<SegSelect v-model="draft.logging.persist_mode" :options="[
{ value: 'summary', label: 'summary' },
{ value: 'compact', label: 'compact' },
@@ -398,6 +407,11 @@ input[type="text"].inp-wide { width: 200px; }
.btn-save:not(:disabled):hover { filter: brightness(1.1); }
/* 保存提示 */
.restart-notice {
font-size: 11px; padding: 5px 8px; margin-bottom: 4px;
border-radius: 6px; color: var(--yellow);
background: color-mix(in srgb, var(--yellow) 10%, transparent);
}
.save-msg {
font-size: 11px; padding: 5px 8px;
border-radius: 6px; word-break: break-all;

View File

@@ -76,6 +76,12 @@
<div v-if="req.error" class="rerr">{{ req.error }}</div>
</div>
</div>
<!-- 加载更多 SQLite 模式下有数据时显示 -->
<div v-if="logsStore.hasMore" class="load-more">
<button class="lm-btn" :disabled="logsStore.loadingMore" @click="logsStore.loadMoreRequests()">
{{ logsStore.loadingMore ? '加载中...' : `加载更多已显示 ${logsStore.reqs.length} / ${logsStore.total}` }}
</button>
</div>
</div>
</template>
@@ -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; }
</style>

View File

@@ -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<RequestSummary[]>([]);
@@ -9,6 +10,11 @@ export const useLogsStore = defineStore('logs', () => {
const globalLogs = ref<LogEntry[]>([]); // 全局实时日志流(未选中时显示)
const curRequestId = ref<string | null>(null);
const payload = ref<Payload | null>(null);
const hasMore = ref(false);
const loadingMore = ref(false);
const total = ref(0);
const statusCounts = ref<Record<string, number>>({ 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> = {}): 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<typeof setTimeout> | 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,
};
});

View File

@@ -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 {