mirror of
https://github.com/7836246/cursor2api.git
synced 2026-06-04 04:49:33 +08:00
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:
14
README.md
14
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 后端开销) |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
422
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
16
src/index.ts
16
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;
|
||||
|
||||
@@ -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
172
src/logger-db.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 查询 ====================
|
||||
|
||||
/** 按需加载单条 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<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;
|
||||
}
|
||||
239
src/logger.ts
239
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<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 {
|
||||
|
||||
@@ -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 呈现模式
|
||||
|
||||
163
test/migrate-jsonl-to-sqlite.mjs
Normal file
163
test/migrate-jsonl-to-sqlite.mjs
Normal 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
264
test/unit-logger-db.mjs
Normal 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】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);
|
||||
@@ -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.*` 属于启动时初始化参数,修改后**需重启服务**才能生效;其余配置热重载,下一次请求即生效。
|
||||
|
||||
## 与原有日志页面的关系
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 按需查询,彻底避免大文件 OOM;Vue 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user