diff --git a/.dockerignore b/.dockerignore index e27e228..e73872f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ dist *.log data logs +dbdata .claude README.md docker-compose.yml diff --git a/.gitignore b/.gitignore index 6ff2198..6b0d1ab 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ dist/ data/ logs/ +dbdata/ .claude diff --git a/Dockerfile b/Dockerfile old mode 100644 new mode 100755 index c4d29d9..f4e69ad --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ # ===== 构建阶段 ===== FROM node:24-alpine AS builder +# 安装编译依赖(better-sqlite3 需要) +RUN apk add --no-cache python3 make g++ + WORKDIR /app # 先复制依赖文件,利用缓存 @@ -16,7 +19,7 @@ COPY src ./src # 构建项目 RUN npm run build -# 清理 devDependencies +# ★ 关键:在 builder 中就清理掉 devDependencies,这样 COPY 过去的 node_modules 就是干净的 RUN npm prune --omit=dev && \ npm cache clean --force @@ -36,14 +39,14 @@ WORKDIR /app # 从构建阶段复制 package.json(用于 npm start 等元数据) COPY --from=builder /app/package.json ./ -# 从构建阶段复制已清理的 node_modules(仅生产依赖) +# 从构建阶段复制已清理的 node_modules(仅生产依赖 + 已编译的 native 模块) COPY --from=builder /app/node_modules ./node_modules # 从构建阶段复制构建产物 COPY --from=builder /app/dist ./dist # 创建数据目录 -RUN mkdir -p /app/data /app/logs +RUN mkdir -p /app/data /app/dbdata # 环境变量 ENV NODE_ENV=production diff --git a/README.md b/README.md old mode 100644 new mode 100755 index afa0ce0..fd27014 --- a/README.md +++ b/README.md @@ -10,11 +10,12 @@ **API 兼容** - OpenAI `v1/chat/completions`(流式 & 非流式) +- OpenAI `v1/responses`(流式 & 非流式,Codex 兼容) - Anthropic `v1/messages`(流式 & 非流式) - 多模态图片理解(自动上传至小米 OSS) **客户端兼容** -- Cline / Kilo Code / Roo Code / Cursor 等 AI 编程工具 +- Codex CLI / Cline / Kilo Code / Roo Code / Cursor 等 AI 编程工具 - 任何支持 OpenAI 或 Anthropic API 的客户端 **核心能力** @@ -27,7 +28,7 @@ **管理** - Web 管理面板(账号、API 密钥、请求日志、统计图表) - REST 管理 API -- JSON 文件持久化存储 +- SQLite 持久化存储 ## 快速开始 @@ -48,6 +49,70 @@ npm run dev # 开发模式(热重载) > 管理面板默认密码:`admin`,登录后可修改。首次使用需在管理面板创建 API 密钥供客户端调用。 +### 首次运行数据 + +仓库不会包含运行时数据库。别人通过 `git clone` 获取项目后,首次启动会自动创建 `dbdata/mimo-proxy.db`,账号、API Key、配置和日志默认都是空的。 + +注意: + +- `dbdata/` 是本地持久化目录,不要提交到 Git。 +- 如果页面出现旧账号,通常是复用了本机旧的 `dbdata/` 或浏览器 `localStorage` 缓存。 +- 想重置为空系统,停止服务后删除 `dbdata/`,再重新启动。 + +macOS / Linux: + +```bash +rm -rf dbdata +``` + +Windows PowerShell: + +```powershell +Remove-Item -Recurse -Force dbdata +``` + +### 平台差异与 native 依赖 + +本项目使用 SQLite 持久化存储,依赖 `better-sqlite3` 原生模块。该模块会为当前操作系统、CPU 架构和 Node.js 版本生成本地二进制文件,因此 **Windows、macOS、Linux 的 `node_modules` 不能混用**。 + +如果在不同系统之间复制项目目录、从 Windows 同步到 macOS、从 macOS 同步到 Windows、切换 Node.js 版本,或在 macOS 上切换 arm64 / Rosetta x64 运行环境,启动时可能出现类似错误: + +```text +Error: dlopen(.../better_sqlite3.node): slice is not valid mach-o file +Error: ... is not a valid Win32 application +Error: Could not locate the bindings file +``` + +处理方式: + +```bash +# 在当前系统和当前 Node.js 版本下重新编译 better-sqlite3 +npm rebuild better-sqlite3 +``` + +如果仍然报错,删除当前平台不匹配的依赖后重新安装: + +macOS / Linux: + +```bash +rm -rf node_modules +npm install +``` + +Windows PowerShell: + +```powershell +Remove-Item -Recurse -Force node_modules +npm install +``` + +建议: + +- 不要提交或跨平台复制 `node_modules`。 +- 每个系统单独执行 `npm install`。 +- 切换 Node.js 版本后执行 `npm rebuild better-sqlite3`。 +- Docker 部署会在镜像内按 Linux 环境安装和编译依赖,不要把宿主机的 `node_modules` 挂载进容器。 + ### Docker 部署 #### 构建并启动 @@ -72,7 +137,9 @@ docker compose down 数据目录会挂载到宿主机: - `./data` - 应用数据目录 -- `./logs` - 日志目录 +- `./dbdata` - SQLite 数据库目录(含 `mimo-proxy.db`) + +如果需要重置 Docker 部署的数据,先停止容器,再删除宿主机的 `./dbdata` 目录后重新启动。 #### 端口配置 @@ -103,7 +170,7 @@ docker run -d \ --name mimo-proxy \ -p 8080:8080 \ -v $(pwd)/data:/app/data \ - -v $(pwd)/logs:/app/logs \ + -v $(pwd)/dbdata:/app/dbdata \ mimo-proxy ``` @@ -147,6 +214,19 @@ curl http://localhost:8080/v1/chat/completions \ }' ``` +### OpenAI Responses API(Codex 兼容) + +```bash +curl http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "mimo-v2-pro", + "input": [{"role": "user", "content": "你好"}], + "stream": true + }' +``` + ### Anthropic 格式 ```bash @@ -189,7 +269,7 @@ curl http://localhost:8080/v1/chat/completions \ ## 应用配置 -配置通过 **Admin Web UI**(`http://localhost:8080/`)或 **Admin API** 管理,持久化存储在 JSON 文件中。 +配置通过 **Admin Web UI**(`http://localhost:8080/`)或 **Admin API** 管理,持久化存储在 SQLite 数据库中,`.env` 文件不会被读取。 | 配置项 | 默认值 | 说明 | |--------|--------|------| @@ -241,8 +321,8 @@ src/ │ ├── style.css │ ├── input.css │ └── chart.js -├── config.ts # 配置加载(JSON → 内存) -├── db.ts # JSON 文件存储工具 +├── config.ts # 配置加载(数据库 → 内存) +├── db.ts # SQLite 初始化 ├── accounts.ts # 多账号管理 & 负载均衡 ├── api-keys.ts # API 密钥管理 └── index.ts # 入口 diff --git a/check_health.ps1 b/check_health.ps1 new file mode 100755 index 0000000..d1a2b7a --- /dev/null +++ b/check_health.ps1 @@ -0,0 +1,14 @@ +try { + $r = Invoke-WebRequest -Uri 'http://localhost:8080/health' -TimeoutSec 5 + Write-Host "Health Status: $($r.StatusCode)" + Write-Host "Content: $($r.Content)" +} catch { + Write-Host "Health Error: $($_.Exception.Message)" +} + +try { + $r2 = Invoke-WebRequest -Uri 'http://localhost:8080/' -TimeoutSec 5 + Write-Host "Admin UI Status: $($r2.StatusCode)" +} catch { + Write-Host "Admin UI Error: $($_.Exception.Message)" +} diff --git a/docker-compose.yml b/docker-compose.yml old mode 100644 new mode 100755 index 2c86dbc..79f78a0 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: - "8080:8080" volumes: - ./data:/app/data - - ./logs:/app/logs + - ./dbdata:/app/dbdata restart: unless-stopped environment: - NODE_ENV=production diff --git a/package-lock.json b/package-lock.json index 7f3005f..1be6eea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,18 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@esbuild/darwin-arm64": "^0.28.0", "@hono/node-server": "^2.0.3", - "hono": "^4.12.9" + "better-sqlite3": "^12.8.0", + "chart.js": "^4.5.1", + "hono": "^4.12.9", + "undici": "^7.25.0" }, "devDependencies": { "@tailwindcss/cli": "^4.2.2", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.0", "@types/ws": "^8.18.1", - "chart.js": "^4.5.1", "tailwindcss": "^4.2.2", "tsx": "^4.21.0", "typescript": "^6.0.2" @@ -97,9 +101,7 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", - "optional": true, "os": [ "darwin" ], @@ -530,7 +532,6 @@ "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "dev": true, "license": "MIT" }, "node_modules/@parcel/watcher": { @@ -661,9 +662,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -685,9 +683,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -709,9 +704,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -733,9 +725,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +746,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -781,9 +767,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1012,9 +995,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1032,9 +1012,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1052,9 +1029,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1072,9 +1046,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1148,6 +1119,16 @@ "node": ">= 20" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@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/node": { "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", @@ -1168,11 +1149,88 @@ "@types/node": "*" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "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 || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/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/chart.js": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "dev": true, "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -1181,16 +1239,54 @@ "pnpm": ">=8" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/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/enhanced-resolve": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", @@ -1247,6 +1343,27 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/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/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1262,6 +1379,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1278,6 +1401,38 @@ "node": ">=16.9.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1454,9 +1609,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1478,9 +1630,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1502,9 +1651,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1526,9 +1672,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1594,6 +1737,33 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1604,6 +1774,24 @@ "node": ">=4" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -1611,6 +1799,15 @@ "dev": true, "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1631,6 +1828,149 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "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": "^2.0.0", + "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/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/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/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/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/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1641,6 +1981,24 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -1662,6 +2020,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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/tsx": { "version": "4.22.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", @@ -1681,6 +2067,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/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/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1695,12 +2093,33 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" } } } diff --git a/package.json b/package.json old mode 100644 new mode 100755 index 15cc0b7..b18e1c4 --- a/package.json +++ b/package.json @@ -6,10 +6,11 @@ "scripts": { "start": "node dist/index.js", "dev": "tsx watch src/index.ts", + "test": "node --import tsx --test \"test/**/*.test.ts\"", "build:css": "npx @tailwindcss/cli -i src/web/input.css -o src/web/style.css --minify", "build:assets": "cp node_modules/chart.js/dist/chart.umd.min.js src/web/chart.js", "build:ts": "tsc", - "build:web": "cp -r src/web dist/web", + "build:web": "node -e \"const fs=require('fs'); fs.rmSync('dist/web', {recursive:true, force:true}); fs.cpSync('src/web', 'dist/web', {recursive:true})\"", "build": "npm run build:css && npm run build:assets && npm run build:ts && npm run build:web", "dev:css": "npx @tailwindcss/cli -i src/web/input.css -o src/web/style.css --watch" }, @@ -18,14 +19,18 @@ "license": "ISC", "type": "module", "dependencies": { + "@esbuild/darwin-arm64": "^0.28.0", "@hono/node-server": "^2.0.3", - "hono": "^4.12.9" + "better-sqlite3": "^12.8.0", + "chart.js": "^4.5.1", + "hono": "^4.12.9", + "undici": "^7.25.0" }, "devDependencies": { "@tailwindcss/cli": "^4.2.2", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.0", "@types/ws": "^8.18.1", - "chart.js": "^4.5.1", "tailwindcss": "^4.2.2", "tsx": "^4.21.0", "typescript": "^6.0.2" diff --git a/src/accounts.ts b/src/accounts.ts old mode 100644 new mode 100755 index 8ceadde..a6365a4 --- a/src/accounts.ts +++ b/src/accounts.ts @@ -1,4 +1,4 @@ -import { loadAccountData, saveAccountData, AccountRecord } from './db.js'; +import { db } from './db.js'; import { randomUUID } from 'crypto'; export interface Account { @@ -22,111 +22,71 @@ export function createAccount(data: { }) { const id = randomUUID(); const api_key = 'sk-' + randomUUID().replace(/-/g, ''); - - const accountData = loadAccountData(); - const newAccount: AccountRecord = { - id, - alias: data.alias ?? null, - service_token: data.service_token, - user_id: data.user_id, - ph_token: data.ph_token, - api_key, - is_active: 1, - active_requests: 0, - request_count: 0, - created_at: new Date().toISOString(), - }; - - accountData.accounts.push(newAccount); - saveAccountData(accountData); - + db.prepare( + `INSERT INTO accounts (id, alias, service_token, user_id, ph_token, api_key, created_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'))` + ).run(id, data.alias ?? null, data.service_token, data.user_id, data.ph_token, api_key); return { id, api_key }; } export function listAccounts(): Account[] { - const accountData = loadAccountData(); - return accountData.accounts.sort((a, b) => b.created_at.localeCompare(a.created_at)); + return db.prepare('SELECT * FROM accounts ORDER BY created_at DESC').all() as Account[]; } export function getAccountById(id: string): Account | undefined { - const accountData = loadAccountData(); - return accountData.accounts.find(a => a.id === id); + return db.prepare('SELECT * FROM accounts WHERE id = ?').get(id) as Account | undefined; } export function getAccountByApiKey(apiKey: string): Account | undefined { - const accountData = loadAccountData(); - return accountData.accounts.find(a => a.api_key === apiKey && a.is_active === 1); + return db.prepare('SELECT * FROM accounts WHERE api_key = ? AND is_active = 1').get(apiKey) as Account | undefined; } export function getLeastBusyAccount(): Account | undefined { - const accountData = loadAccountData(); - return accountData.accounts - .filter(a => a.is_active === 1) - .sort((a, b) => a.active_requests - b.active_requests)[0]; + return db.prepare( + 'SELECT * FROM accounts WHERE is_active = 1 ORDER BY active_requests ASC LIMIT 1' + ).get() as Account | undefined; } export function acquireAccount(maxConcurrent: number): Account | undefined { - const accountData = loadAccountData(); - - const account = accountData.accounts - .filter(a => a.is_active === 1 && a.active_requests < maxConcurrent) - .sort((a, b) => a.active_requests - b.active_requests || a.request_count - b.request_count)[0]; - - if (!account) return undefined; - - account.active_requests += 1; - account.request_count += 1; - saveAccountData(accountData); - - return { ...account }; + const txn = db.transaction(() => { + const account = db.prepare( + 'SELECT * FROM accounts WHERE is_active = 1 AND active_requests < ? ORDER BY active_requests ASC, request_count ASC LIMIT 1' + ).get(maxConcurrent) as Account | undefined; + if (!account) return undefined; + db.prepare('UPDATE accounts SET active_requests = active_requests + 1, request_count = request_count + 1 WHERE id = ?').run(account.id); + return { ...account, active_requests: account.active_requests + 1, request_count: account.request_count + 1 }; + }); + return txn(); } export function incrementActive(id: string) { - const accountData = loadAccountData(); - const account = accountData.accounts.find(a => a.id === id); - if (account) { - account.active_requests += 1; - saveAccountData(accountData); - } + db.prepare('UPDATE accounts SET active_requests = active_requests + 1 WHERE id = ?').run(id); } export function decrementActive(id: string) { - const accountData = loadAccountData(); - const account = accountData.accounts.find(a => a.id === id); - if (account) { - account.active_requests = Math.max(0, account.active_requests - 1); - saveAccountData(accountData); - } + db.prepare('UPDATE accounts SET active_requests = MAX(0, active_requests - 1) WHERE id = ?').run(id); } export function updateAccount(id: string, data: { alias?: string; is_active?: number }) { - const accountData = loadAccountData(); - const account = accountData.accounts.find(a => a.id === id); - if (!account) return; - - if (data.alias !== undefined) account.alias = data.alias; - if (data.is_active !== undefined) account.is_active = data.is_active; - - saveAccountData(accountData); + const fields: string[] = []; + const values: unknown[] = []; + if (data.alias !== undefined) { fields.push('alias = ?'); values.push(data.alias); } + if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); } + if (!fields.length) return; + values.push(id); + db.prepare(`UPDATE accounts SET ${fields.join(', ')} WHERE id = ?`).run(...values); } export function deleteAccount(id: string) { - const accountData = loadAccountData(); - accountData.accounts = accountData.accounts.filter(a => a.id !== id); - saveAccountData(accountData); + db.prepare('DELETE FROM accounts WHERE id = ?').run(id); } export function markAccountInactive(id: string) { - const accountData = loadAccountData(); - const account = accountData.accounts.find(a => a.id === id); - if (account) { - account.is_active = 0; - saveAccountData(accountData); - } + db.prepare('UPDATE accounts SET is_active = 0 WHERE id = ?').run(id); } export function parseCurl(curl: string): { service_token: string; user_id: string; ph_token: string } | null { - const m1 = curl.match(/(?:-b|--cookie)\s+'([^']+)'/) ?? curl.match(/(?:-b|--cookie)\s+"([^"]+)"/); + const m1 = curl.match(/(?:-b|--cookie)\s+'([^']+)'/) ?? curl.match(/(?:-b|--cookie)\s+"([^"]+)"/) ; const m2 = curl.match(/-H\s+[Cc]ookie:\s*([^\r\n]+)/); const cookies = m1?.[1] ?? m2?.[1]; if (!cookies) return null; diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts old mode 100644 new mode 100755 index 6681b48..3c9db47 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -3,8 +3,8 @@ import { stream } from 'hono/streaming'; import { randomUUID } from 'crypto'; import { decrementActive } from '../accounts.js'; import { callMimo, MimoUsage, fetchBotConfig } from '../mimo/client.js'; -import { serializeMessages, ChatMessage } from '../mimo/serialize.js'; -import { config, debugLog } from '../config.js'; +import { serializeMessages, ChatMessage, sanitizeOutput } from '../mimo/serialize.js'; +import { config } from '../config.js'; import { buildToolSystemPrompt, ToolDefinition } from '../tools/prompt.js'; import { parseToolCalls, hasToolCallMarker, findEarliestToolCallMarker } from '../tools/parser.js'; import { toAnthropicToolUse } from '../tools/format.js'; @@ -14,6 +14,8 @@ import { getOrCreateSession, updateSessionTokens } from '../mimo/session.js'; import { extractApiKey, authenticateRequest, acquireAccountForRequest, logApiRequest, handleAccountError } from '../middleware/request-handler.js'; import { generateClientSessionId } from '../mimo/session-marker.js'; +const DEBUG = process.env.DEBUG_MIMO === '1' || process.env.DEBUG_MIMO === 'true'; + // 静态 fallback const MODEL_MAP: Record = { 'mimo-v2.5-pro': 'mimo-v2.5-pro', @@ -63,12 +65,15 @@ function resolveModel(model: string): string { function logRequest(data: { account_id: string; + session_id?: string | null; api_key_id: string | null; model: string; usage: MimoUsage | null; status: 'success' | 'error'; error?: string; duration_ms: number; + request_body?: string | null; + response_body?: string | null; }) { logApiRequest({ ...data, endpoint: 'anthropic' }); } @@ -131,7 +136,7 @@ function buildMessages(body: Record): ChatMessage[] { } else if (b.type === 'tool_result') { const resultContent = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? (b.content as Array<{type:string;text?:string}>).filter(x=>x.type==='text').map(x=>x.text??'').join('') : JSON.stringify(b.content); - parts.push(`[Tool Result]\n${resultContent}`); + parts.push(`[工具结果]\n${resultContent}`); } } content = parts.join('\n'); @@ -152,26 +157,46 @@ function processThinkContent(text: string): { thinkContent: string; mainContent: return { thinkContent: '', mainContent: text }; } +function previewApiKey(apiKey: string): string { + if (!apiKey) return 'missing'; + if (apiKey.length <= 12) return `${apiKey.slice(0, 4)}...`; + return `${apiKey.slice(0, 8)}...${apiKey.slice(-4)}`; +} + export function registerAnthropic(app: Hono) { app.post('/v1/messages', async (c) => { console.log('\n[REQ] ========== New Anthropic Request =========='); + console.log('[REQ] Method/Path:', c.req.method, c.req.path); const apiKey = extractApiKey(c); + console.log('[AUTH] API key:', previewApiKey(apiKey)); // 1. 认证检查 const apiKeyRecord = authenticateRequest(apiKey); if (!apiKeyRecord) { + console.warn('[AUTH] Failed:', apiKey ? 'invalid API key' : 'missing API key'); return c.json({ type: 'error', error: { type: 'authentication_error', message: apiKey ? 'Invalid API key' : 'Missing API key' } }, 401); } + console.log('[AUTH] OK:', { apiKeyId: apiKeyRecord.id, name: apiKeyRecord.name }); // 2. 原子性选择账号并递增并发计数 const acquired = acquireAccountForRequest(apiKeyRecord); if (!acquired) { + console.warn('[ACCOUNT] No active account available'); return c.json({ type: 'error', error: { type: 'service_error', message: 'No active account available' } }, 503); } const { account } = acquired; + console.log('[ACCOUNT] Acquired:', { accountId: account.id, alias: account.alias, activeRequests: account.active_requests }); - const body = await c.req.json(); + let body: Record; + try { + body = await c.req.json(); + } catch (err) { + console.error('[REQ] Failed to parse JSON body:', err); + decrementActive(account.id); + return c.json({ type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON body' } }, 400); + } + const requestBody = JSON.stringify(body); console.log('[REQ] Body parsed:', { model: body.model || 'default', stream: body.stream ?? false, messages: body.messages?.length || 0, tools: body.tools?.length || 0, thinking: body.thinking?.type === 'enabled' }); console.log('[ANT] tools:', JSON.stringify(body.tools?.map((t: Record) => t.name ?? t.function) ?? null)); @@ -179,6 +204,7 @@ export function registerAnthropic(app: Hono) { const mimoModel = await getResolvedModel(body.model ?? ''); const isStream: boolean = body.stream ?? false; const enableThinking: boolean = body.thinking?.type === 'enabled'; + const responseThinkMode = enableThinking ? config.thinkMode : 'strip'; const tools: ToolDefinition[] | undefined = body.tools?.length ? body.tools : undefined; let messages = buildMessages(body); if (tools) { @@ -198,7 +224,7 @@ export function registerAnthropic(app: Hono) { try { // 1. 生成客户端会话标识(备用) - const clientSessionId = generateClientSessionId(c, account.id); + const clientSessionId = generateClientSessionId(c, account.id, null, config.sessionIsolation); // 2. 获取或创建会话(基于消息历史连续性) const { conversationId, session } = await getOrCreateSession( @@ -228,6 +254,8 @@ export function registerAnthropic(app: Hono) { let isAborted = false; let eventCount = 0; let loggedError = false; + let responseBodyStr: string | null = null; + let responseContentBuf = ''; const req = c.req.raw as any; if (req.on) { @@ -243,6 +271,13 @@ export function registerAnthropic(app: Hono) { try { await s.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); eventCount++; + // Track text content for response logging + if (event === 'content_block_delta') { + const d = data as any; + if (d?.delta?.type === 'text_delta' && d?.delta?.text) { + responseContentBuf += d.delta.text; + } + } } catch (err) { console.error('[STREAM] ❌ Write error:', err); isAborted = true; @@ -274,7 +309,6 @@ export function registerAnthropic(app: Hono) { if (chunk.type === 'text') { let text = (chunk.content ?? '').replace(/\u0000/g, ''); - if (text) debugLog('[DBG] chunk:', JSON.stringify(text.slice(0, 80)), 'pastThink:', pastThink, 'tcBuf:', toolCallBuf !== null); if (!pastThink && !thinkingStarted && text && !text.includes('')) { pastThink = true; if (!firstBlockSent) { @@ -286,7 +320,7 @@ export function registerAnthropic(app: Hono) { if (!thinkingStarted && text.includes('')) { thinkingStarted = true; text = text.replace('', ''); - if (config.thinkMode === 'separate') { + if (responseThinkMode === 'separate') { await sendEvent('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }); firstBlockSent = true; } else if (!firstBlockSent) { @@ -299,25 +333,23 @@ export function registerAnthropic(app: Hono) { pastThink = true; const thinkPart = text.slice(0, closeIdx); const afterThink = text.slice(closeIdx + 8).trimStart(); - if (config.thinkMode === 'separate') { + if (responseThinkMode === 'separate') { if (thinkPart) { - debugLog('[DBG] Sending thinking_delta:', JSON.stringify(thinkPart.slice(0, 50))); await sendEvent('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: thinkPart } }); } await sendEvent('content_block_stop', { type: 'content_block_stop', index: 0 }); await sendEvent('content_block_start', { type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } }); - } else if (config.thinkMode === 'passthrough') { + } else if (responseThinkMode === 'passthrough') { thinkBuf += thinkPart; await sendEvent('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '' + thinkBuf + '' } }); } if (afterThink) { text = afterThink; } else { continue; } } else { - if (config.thinkMode === 'separate') { + if (responseThinkMode === 'separate') { if (text) { - debugLog('[DBG] Sending thinking_delta chunk:', JSON.stringify(text.slice(0, 50))); await sendEvent('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: text } }); } - } else if (config.thinkMode === 'passthrough') { + } else if (responseThinkMode === 'passthrough') { thinkBuf += text; } continue; @@ -330,7 +362,7 @@ export function registerAnthropic(app: Hono) { if (t2Idx !== -1) text = text.slice(0, t2Idx); if (!text) continue; - const idx = config.thinkMode === 'separate' ? 1 : 0; + const idx = responseThinkMode === 'separate' ? 1 : 0; if (toolCallBuf !== null) { toolCallBuf += text; } else { @@ -359,21 +391,21 @@ export function registerAnthropic(app: Hono) { } if (!pastThink && thinkingStarted) { pastThink = true; - if (config.thinkMode === 'separate') { + if (responseThinkMode === 'separate') { await sendEvent('content_block_stop', { type: 'content_block_stop', index: 0 }); await sendEvent('content_block_start', { type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } }); - } else if (config.thinkMode === 'passthrough') { + } else if (responseThinkMode === 'passthrough') { await sendEvent('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '' + thinkBuf + '' } }); } } if (pendingText) { - const idx2 = config.thinkMode === 'separate' ? 1 : 0; + const idx2 = responseThinkMode === 'separate' ? 1 : 0; if (toolCallBuf !== null) toolCallBuf += pendingText; else if (hasToolCallMarker(pendingText)) toolCallBuf = pendingText; - else await sendEvent('content_block_delta', { type: 'content_block_delta', index: idx2, delta: { type: 'text_delta', text: pendingText } }); + else await sendEvent('content_block_delta', { type: 'content_block_delta', index: idx2, delta: { type: 'text_delta', text: sanitizeOutput(pendingText) } }); pendingText = ''; } - const lastIdx = config.thinkMode === 'separate' && pastThink ? 1 : 0; + const lastIdx = responseThinkMode === 'separate' && pastThink ? 1 : 0; await sendEvent('content_block_stop', { type: 'content_block_stop', index: lastIdx }); let stopReason = 'end_turn'; if (toolCallBuf && hasToolCallMarker(toolCallBuf)) { @@ -406,6 +438,14 @@ export function registerAnthropic(app: Hono) { } } clearInterval(pingTimer!); + // Build response body for logging + const logRespObj: any = { finish_reason: stopReason, content: responseContentBuf }; + if (stopReason === 'tool_use' && toolCallBuf && hasToolCallMarker(toolCallBuf)) { + const parsedCalls = parseToolCalls(toolCallBuf); + if (parsedCalls.length > 0) logRespObj.tool_calls = parsedCalls.map(tc => ({ name: tc.name, arguments: tc.arguments })); + } + if (lastUsage) logRespObj.usage = { prompt_tokens: lastUsage.promptTokens, completion_tokens: lastUsage.completionTokens }; + responseBodyStr = JSON.stringify(logRespObj); await sendEvent('message_delta', { type: 'message_delta', delta: { stop_reason: stopReason, stop_sequence: null }, usage: { input_tokens: lastUsage?.promptTokens ?? 0, output_tokens: lastUsage?.completionTokens ?? 0 } }); await sendEvent('message_stop', { type: 'message_stop' }); console.log('[STREAM] ✓ Completed:', { events: eventCount, stopReason, tokens: lastUsage?.totalTokens || 0, duration: Date.now() - startTime + 'ms' }); @@ -416,13 +456,13 @@ export function registerAnthropic(app: Hono) { if (!isAborted) { try { await sendEvent('error', { type: 'error', error: { type: 'api_error', message: String(err) } }); } catch {} } - logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'error', error: String(err), duration_ms: Date.now() - startTime }); + logRequest({ account_id: account.id, session_id: session.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'error', error: String(err), duration_ms: Date.now() - startTime, request_body: requestBody, response_body: responseBodyStr }); loggedError = true; } finally { if (pingTimer) clearInterval(pingTimer); decrementActive(account.id); if (!loggedError) { - logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime }); + logRequest({ account_id: account.id, session_id: session.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime, request_body: requestBody, response_body: responseBodyStr }); if (lastUsage) { updateSessionTokens(session.id, lastUsage.promptTokens); } @@ -439,16 +479,16 @@ export function registerAnthropic(app: Hono) { else if (chunk.type === 'usage') lastUsage = chunk.usage!; } - if (config.thinkMode === 'strip') { + if (responseThinkMode === 'strip') { fullText = fullText.replace(/[\s\S]*?<\/think>/g, '').trimStart(); } const content: unknown[] = []; - if (config.thinkMode === 'separate') { + if (responseThinkMode === 'separate') { const { thinkContent, mainContent } = processThinkContent(fullText); if (thinkContent) content.push({ type: 'thinking', thinking: thinkContent }); - content.push({ type: 'text', text: mainContent }); + content.push({ type: 'text', text: sanitizeOutput(mainContent) }); } else { - content.push({ type: 'text', text: fullText }); + content.push({ type: 'text', text: sanitizeOutput(fullText) }); } let stopReason = 'end_turn'; if (hasToolCallMarker(fullText)) { @@ -458,7 +498,8 @@ export function registerAnthropic(app: Hono) { for (const block of toAnthropicToolUse(calls)) content.push(block); } } - logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime }); + const nonStreamRespBody = JSON.stringify({ content: sanitizeOutput(fullText), usage: lastUsage ? { prompt_tokens: lastUsage.promptTokens, completion_tokens: lastUsage.completionTokens } : undefined }); + logRequest({ account_id: account.id, session_id: session.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime, request_body: requestBody, response_body: nonStreamRespBody }); // 更新会话 token 统计 if (lastUsage) { updateSessionTokens(session.id, lastUsage.promptTokens); @@ -471,7 +512,7 @@ export function registerAnthropic(app: Hono) { } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); handleAccountError(account, msg); - logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: null, status: 'error', error: msg, duration_ms: Date.now() - startTime }); + logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: null, status: 'error', error: msg, duration_ms: Date.now() - startTime, request_body: requestBody }); return c.json({ type: 'error', error: { type: 'api_error', message: msg } }, 502); } finally { if (!isStream) decrementActive(account.id); diff --git a/src/adapters/openai-normalize.ts b/src/adapters/openai-normalize.ts new file mode 100644 index 0000000..970b1ad --- /dev/null +++ b/src/adapters/openai-normalize.ts @@ -0,0 +1,167 @@ +import { ChatMessage } from '../mimo/serialize.js'; + +export class OpenAIRequestError extends Error { + status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = 'OpenAIRequestError'; + this.status = status; + } +} + +export interface NormalizedOpenAIRequest { + messages: ChatMessage[]; + sessionKey: string | null; +} + +type AnyRecord = Record; + +function normalizeRole(role: unknown): ChatMessage['role'] { + if (role === 'developer' || role === 'system') return 'system'; + if (role === 'assistant') return 'assistant'; + if (role === 'tool' || role === 'function') return 'tool'; + return 'user'; +} + +function textFromContentParts(parts: unknown[]): string { + return parts + .map((part) => { + if (typeof part === 'string') return part; + if (!part || typeof part !== 'object') return ''; + const p = part as AnyRecord; + if (typeof p.text === 'string') return p.text; + if (typeof p.output_text === 'string') return p.output_text; + return ''; + }) + .filter(Boolean) + .join('\n'); +} + +function normalizeChatContent(content: unknown, role: ChatMessage['role']): string | null { + if (content == null) return null; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return role === 'system' ? textFromContentParts(content) : content as any; + } + return JSON.stringify(content); +} + +function normalizeResponseContent(content: unknown): string { + if (content == null) return ''; + if (typeof content === 'string') return content; + if (Array.isArray(content)) return textFromContentParts(content); + return JSON.stringify(content); +} + +function normalizeMessages(messages: unknown): ChatMessage[] { + if (!Array.isArray(messages)) { + throw new OpenAIRequestError('messages must be an array'); + } + + return messages.map((message) => { + const m = (message ?? {}) as AnyRecord; + const role = normalizeRole(m.role); + return { + ...m, + role, + content: normalizeChatContent(m.content, role), + } as ChatMessage; + }); +} + +function normalizeInput(input: unknown): ChatMessage[] { + if (typeof input === 'string') { + return [{ role: 'user', content: input }]; + } + + if (!Array.isArray(input)) { + throw new OpenAIRequestError('input must be a string or an array'); + } + + const messages: ChatMessage[] = []; + for (const item of input) { + if (typeof item === 'string') { + messages.push({ role: 'user', content: item }); + continue; + } + + if (!item || typeof item !== 'object') continue; + const i = item as AnyRecord; + + if (i.type === 'function_call_output') { + messages.push({ + role: 'tool', + content: normalizeResponseContent(i.output), + tool_call_id: i.call_id, + }); + continue; + } + + if (i.type === 'function_call') { + messages.push({ + role: 'assistant', + content: null, + tool_calls: [{ + id: i.call_id ?? i.id ?? '', + type: 'function', + function: { + name: i.name ?? 'unknown', + arguments: typeof i.arguments === 'string' ? i.arguments : JSON.stringify(i.arguments ?? {}), + }, + }], + }); + continue; + } + + messages.push({ + role: normalizeRole(i.role), + content: normalizeResponseContent(i.content ?? i.text), + }); + } + + return messages; +} + +function normalizeSystemInstructions(instructions: unknown): ChatMessage[] { + const content = normalizeResponseContent(instructions); + return content ? [{ role: 'system', content }] : []; +} + +function extractSessionKey(body: AnyRecord): string | null { + const conversation = body.conversation; + if (typeof conversation === 'string' && conversation) return `conversation:${conversation}`; + if (conversation && typeof conversation === 'object' && typeof conversation.id === 'string') { + return `conversation:${conversation.id}`; + } + if (typeof body.previous_response_id === 'string' && body.previous_response_id) { + return `previous_response:${body.previous_response_id}`; + } + return null; +} + +export function normalizeOpenAIRequestBody(body: unknown): NormalizedOpenAIRequest { + if (!body || typeof body !== 'object') { + throw new OpenAIRequestError('Request body must be a JSON object'); + } + + const b = body as AnyRecord; + const baseMessages = b.messages !== undefined + ? normalizeMessages(b.messages) + : b.input !== undefined + ? [...normalizeSystemInstructions(b.instructions), ...normalizeInput(b.input)] + : null; + + if (!baseMessages) { + throw new OpenAIRequestError('Request body must include messages or input'); + } + + if (baseMessages.length === 0) { + throw new OpenAIRequestError('Request body must include at least one message'); + } + + return { + messages: baseMessages, + sessionKey: extractSessionKey(b), + }; +} diff --git a/src/adapters/openai.ts b/src/adapters/openai.ts old mode 100644 new mode 100755 index e9628c7..283504d --- a/src/adapters/openai.ts +++ b/src/adapters/openai.ts @@ -3,7 +3,7 @@ import { stream } from 'hono/streaming'; import { randomUUID } from 'crypto'; import { decrementActive } from '../accounts.js'; import { callMimo, MimoUsage, fetchBotConfig, getChatModels } from '../mimo/client.js'; -import { serializeMessages, ChatMessage } from '../mimo/serialize.js'; +import { serializeMessages, ChatMessage, sanitizeOutput } from '../mimo/serialize.js'; import { config, debugLog } from '../config.js'; import { buildToolSystemPrompt, ToolDefinition } from '../tools/prompt.js'; import { parseToolCalls, hasToolCallMarker, findEarliestToolCallMarker } from '../tools/parser.js'; @@ -13,6 +13,7 @@ import { Account } from '../accounts.js'; import { getOrCreateSession, updateSessionTokens } from '../mimo/session.js'; import { extractApiKey, authenticateRequest, acquireAccountForRequest, logApiRequest, handleAccountError } from '../middleware/request-handler.js'; import { generateClientSessionId } from '../mimo/session-marker.js'; +import { normalizeOpenAIRequestBody, OpenAIRequestError } from './openai-normalize.js'; // 静态 fallback(网络失败时使用) const MODEL_MAP: Record = { @@ -142,12 +143,15 @@ async function extractImages(account: Account, messages: Array<{ role: string; c function logRequest(data: { account_id: string; + session_id?: string | null; api_key_id: string | null; model: string; usage: MimoUsage | null; status: 'success' | 'error'; error?: string; duration_ms: number; + request_body?: string | null; + response_body?: string | null; }) { logApiRequest({ ...data, endpoint: 'openai' }); } @@ -168,6 +172,283 @@ export function registerOpenAI(app: Hono) { } }); + // OpenAI Responses API (Codex compatibility) + app.post('/v1/responses', async (c) => { + console.log('\n[REQ] ========== New Responses API Request =========='); + console.log('[REQ] Time:', new Date().toISOString()); + + const startTime = Date.now(); + const apiKey = extractApiKey(c); + + const apiKeyRecord = authenticateRequest(apiKey); + if (!apiKeyRecord) { + return c.json({ error: { message: apiKey ? 'Invalid API key' : 'Missing API key', type: 'auth_error' } }, 401); + } + + const acquired = acquireAccountForRequest(apiKeyRecord); + if (!acquired) { + return c.json({ error: { message: 'No active account available', type: 'service_error' } }, 503); + } + const { account } = acquired; + + let requestBody: string | null = null; + let isStream = false; + let streamStarted = false; + let mimoModel = 'mimo-v2-pro'; + let lastUsage: MimoUsage | null = null; + + try { + let body: Record; + try { + body = await c.req.json(); + } catch (err) { + return c.json({ error: { message: 'Invalid JSON body', type: 'invalid_request_error' } }, 400); + } + + requestBody = JSON.stringify(body); + const normalized = normalizeOpenAIRequestBody(body); + isStream = body.stream ?? false; + const tools: ToolDefinition[] | undefined = body.tools?.length ? body.tools : undefined; + const enableThinking: boolean = !!body.reasoning_effort; + mimoModel = await getResolvedModel(body.model ?? ''); + + const { messages: cleanedMsgs, medias } = await extractImages(account, normalized.messages); + const rawMessages: ChatMessage[] = cleanedMsgs as ChatMessage[]; + + let messages = rawMessages; + if (tools) { + const toolPrompt = buildToolSystemPrompt(tools); + const sysIdx = messages.findIndex(m => m.role === 'system'); + if (sysIdx >= 0) { + messages = messages.map((m, i) => i === sysIdx ? { ...m, content: m.content + '\n\n' + toolPrompt } : m); + } else { + messages = [{ role: 'system', content: toolPrompt }, ...messages]; + } + } + + const clientSessionId = generateClientSessionId(c, account.id, normalized.sessionKey, config.sessionIsolation); + const { conversationId, session } = await getOrCreateSession(account.id, clientSessionId, rawMessages); + + const query = serializeMessages(messages); + const gen = callMimo(account, conversationId, query, enableThinking, mimoModel, medias); + const responseId = `resp_${randomUUID().replace(/-/g, '')}`; + const created = Math.floor(Date.now() / 1000); + + if (isStream) { + c.header('Content-Type', 'text/event-stream'); + c.header('Cache-Control', 'no-cache'); + c.header('X-Accel-Buffering', 'no'); + streamStarted = true; + return stream(c, async (s) => { + let isAborted = false; + let loggedError = false; + let seqNum = 0; + + const req = c.req.raw as any; + if (req.on) { + req.on('close', () => { isAborted = true; }); + } + + const sendEvent = async (event: string, data: object) => { + if (isAborted) return; + try { + const payload = { ...data, sequence_number: seqNum++ }; + await s.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`); + } catch (err) { + isAborted = true; + throw err; + } + }; + + const msgId = `msg_${randomUUID().replace(/-/g, '')}`; + + const buildResponseObj = (outputText: string, status: string) => ({ + id: responseId, + object: 'response', + created_at: created, + status, + model: mimoModel, + output: outputText + ? [{ + type: 'message', + id: msgId, + role: 'assistant', + content: [{ type: 'output_text', text: outputText }], + status: 'completed', + }] + : [], + usage: lastUsage + ? { input_tokens: lastUsage.promptTokens, output_tokens: lastUsage.completionTokens, total_tokens: lastUsage.totalTokens } + : undefined, + }); + + try { + // response.created — envelope: { type, response } + await sendEvent('response.created', { + type: 'response.created', + response: buildResponseObj('', 'in_progress'), + }); + + // response.in_progress + await sendEvent('response.in_progress', { + type: 'response.in_progress', + response: buildResponseObj('', 'in_progress'), + }); + + // response.output_item.added + await sendEvent('response.output_item.added', { + type: 'response.output_item.added', + response_id: responseId, + output_index: 0, + item: { type: 'message', id: msgId, role: 'assistant', content: [], status: 'in_progress' }, + }); + + // response.content_part.added + await sendEvent('response.content_part.added', { + type: 'response.content_part.added', + response_id: responseId, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: '' }, + }); + + let pastThink = false; + let thinkingStarted = false; + let outputText = ''; + + for await (const chunk of gen) { + if (isAborted) break; + + if (chunk.type === 'text') { + let text = (chunk.content ?? '').replace(/\u0000/g, ''); + + // Skip thinking blocks + if (!pastThink && !thinkingStarted && text && !text.includes('')) pastThink = true; + if (!pastThink) { + if (!thinkingStarted && text.includes('')) { thinkingStarted = true; text = text.replace('', ''); } + const closeIdx = text.indexOf(''); + if (closeIdx !== -1) { + pastThink = true; + text = text.slice(closeIdx + 8).trimStart(); + if (!text) continue; + } else { + continue; + } + } + if (pastThink) { + text = text.replace(/[\s\S]*?<\/think>/g, ''); + const t2Idx = text.indexOf(''); + if (t2Idx !== -1) text = text.slice(0, t2Idx); + text = text.replace(/[\s\S]*?<\/thinking>/gi, ''); + const t3Idx = text.indexOf(''); + if (t3Idx !== -1) text = text.slice(0, t3Idx); + if (!text) continue; + + outputText += text; + await sendEvent('response.output_text.delta', { + type: 'response.output_text.delta', + response_id: responseId, + output_index: 0, + content_index: 0, + delta: text, + }); + } + } else if (chunk.type === 'usage') { + lastUsage = chunk.usage!; + } else if (chunk.type === 'finish') { + outputText = processThinkContent(outputText, config.thinkMode); + + // output_text.done + await sendEvent('response.output_text.done', { + type: 'response.output_text.done', + response_id: responseId, + output_index: 0, + content_index: 0, + text: outputText, + }); + + // content_part.done + await sendEvent('response.content_part.done', { + type: 'response.content_part.done', + response_id: responseId, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: outputText }, + }); + + // output_item.done + await sendEvent('response.output_item.done', { + type: 'response.output_item.done', + response_id: responseId, + output_index: 0, + item: { + type: 'message', id: msgId, role: 'assistant', + content: [{ type: 'output_text', text: outputText }], + status: 'completed', + }, + }); + + // response.completed — envelope: { type, response } + await sendEvent('response.completed', { + type: 'response.completed', + response: buildResponseObj(outputText, 'completed'), + }); + } + } + + logRequest({ account_id: account.id, session_id: session.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime, request_body: requestBody }); + if (lastUsage) updateSessionTokens(session.id, lastUsage.promptTokens); + loggedError = true; + } catch (err) { + console.error('[RESPONSES] Streaming error:', err); + if (!isAborted) { + try { + await sendEvent('error', { type: 'error', message: String(err) }); + } catch {} + } + logRequest({ account_id: account.id, session_id: session.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'error', error: String(err), duration_ms: Date.now() - startTime, request_body: requestBody }); + loggedError = true; + } finally { + decrementActive(account.id); + if (!loggedError) { + logRequest({ account_id: account.id, session_id: session.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime, request_body: requestBody }); + if (lastUsage) updateSessionTokens(session.id, lastUsage.promptTokens); + } + } + }); + } + + // Non-streaming + let fullText = ''; + for await (const chunk of gen) { + if (chunk.type === 'text') fullText += chunk.content ?? ''; + else if (chunk.type === 'usage') lastUsage = chunk.usage!; + } + + fullText = processThinkContent(fullText, config.thinkMode); + logRequest({ account_id: account.id, session_id: session.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime, request_body: requestBody }); + if (lastUsage) updateSessionTokens(session.id, lastUsage.promptTokens); + + return c.json({ + id: responseId, object: 'response', created_at: created, model: mimoModel, + output: [{ type: 'message', id: `msg_${randomUUID().replace(/-/g, '')}`, role: 'assistant', content: [{ type: 'output_text', text: sanitizeOutput(fullText) }], status: 'completed' }], + usage: lastUsage ? { input_tokens: lastUsage.promptTokens, output_tokens: lastUsage.completionTokens, total_tokens: lastUsage.totalTokens } : undefined, + status: 'completed', + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (err instanceof OpenAIRequestError) { + logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: null, status: 'error', error: msg, duration_ms: Date.now() - startTime, request_body: requestBody }); + return c.json({ error: { message: msg, type: 'invalid_request_error' } }, err.status as any); + } + handleAccountError(account, msg); + logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: null, status: 'error', error: msg, duration_ms: Date.now() - startTime, request_body: requestBody }); + return c.json({ error: { message: msg, type: 'api_error' } }, 502); + } finally { + if (!streamStarted) decrementActive(account.id); + } + }); + app.post('/v1/chat/completions', async (c) => { console.log('\n[REQ] ========== New OpenAI Request =========='); console.log('[REQ] Time:', new Date().toISOString()); @@ -189,34 +470,54 @@ export function registerOpenAI(app: Hono) { } const { account } = acquired; - const body = await c.req.json(); - console.log('[REQ] Body parsed:', { model: body.model || 'default', stream: body.stream ?? false, messages: body.messages?.length || 0, tools: body.tools?.length || 0, reasoning: !!body.reasoning_effort }); - - const { messages: cleanedMsgs, medias } = await extractImages(account, body.messages ?? []); - const rawMessages: ChatMessage[] = cleanedMsgs as ChatMessage[]; - const tools: ToolDefinition[] | undefined = body.tools?.length ? body.tools : undefined; - const isStream: boolean = body.stream ?? false; - const enableThinking: boolean = !!body.reasoning_effort; - const mimoModel = await getResolvedModel(body.model ?? ''); - - let messages = rawMessages; - if (tools) { - console.log('[REQ] 🔧 Tools:', tools.map(t => t.name || (t as any).function?.name).join(', ')); - const toolPrompt = buildToolSystemPrompt(tools); - const sysIdx = messages.findIndex(m => m.role === 'system'); - if (sysIdx >= 0) { - messages = messages.map((m, i) => i === sysIdx ? { ...m, content: m.content + '\n\n' + toolPrompt } : m); - } else { - messages = [{ role: 'system', content: toolPrompt }, ...messages]; - } - } - - console.log('[REQ] 🚀 Starting request processing...'); + let requestBody: string | null = null; + let isStream = false; + let streamStarted = false; + let mimoModel = 'mimo-v2-pro'; let lastUsage: MimoUsage | null = null; try { + let body: Record; + try { + body = await c.req.json(); + } catch (err) { + console.error('[REQ] Failed to parse JSON body:', err); + return c.json({ error: { message: 'Invalid JSON body', type: 'invalid_request_error' } }, 400); + } + + requestBody = JSON.stringify(body); + const normalized = normalizeOpenAIRequestBody(body); + console.log('[REQ] Body parsed:', { + model: body.model || 'default', + stream: body.stream ?? false, + messages: normalized.messages.length, + tools: body.tools?.length || 0, + reasoning: !!body.reasoning_effort, + sessionKey: normalized.sessionKey ?? null + }); + + const { messages: cleanedMsgs, medias } = await extractImages(account, normalized.messages); + const rawMessages: ChatMessage[] = cleanedMsgs as ChatMessage[]; + const tools: ToolDefinition[] | undefined = body.tools?.length ? body.tools : undefined; + isStream = body.stream ?? false; + const enableThinking: boolean = !!body.reasoning_effort; + mimoModel = await getResolvedModel(body.model ?? ''); + + let messages = rawMessages; + if (tools) { + console.log('[REQ] 🔧 Tools:', tools.map(t => t.name || (t as any).function?.name).join(', ')); + const toolPrompt = buildToolSystemPrompt(tools); + const sysIdx = messages.findIndex(m => m.role === 'system'); + if (sysIdx >= 0) { + messages = messages.map((m, i) => i === sysIdx ? { ...m, content: m.content + '\n\n' + toolPrompt } : m); + } else { + messages = [{ role: 'system', content: toolPrompt }, ...messages]; + } + } + + console.log('[REQ] 🚀 Starting request processing...'); // 1. 生成客户端会话标识(备用) - const clientSessionId = generateClientSessionId(c, account.id); + const clientSessionId = generateClientSessionId(c, account.id, normalized.sessionKey, config.sessionIsolation); // 2. 获取或创建会话(基于消息历史连续性) const { conversationId, session } = await getOrCreateSession( @@ -243,10 +544,12 @@ export function registerOpenAI(app: Hono) { c.header('Content-Type', 'text/event-stream'); c.header('Cache-Control', 'no-cache'); c.header('X-Accel-Buffering', 'no'); + streamStarted = true; return stream(c, async (s) => { let isAborted = false; let chunkCount = 0; let loggedError = false; + let responseBodyStr: string | null = null; const req = c.req.raw as any; if (req.on) { @@ -464,7 +767,15 @@ export function registerOpenAI(app: Hono) { await sendDelta({ content: toolCallBuf }); } } - await s.write(`data: ${JSON.stringify({ id: responseId, object: 'chat.completion.chunk', created, model: mimoModel, system_fingerprint: `fp_mimo_${created}`, choices: [{ index: 0, delta: {}, finish_reason: finishReason }], usage: usageChunk })}\n\n`); + // Build response body for logging + const logRespObj: any = { finish_reason: finishReason }; + if (finishReason === 'tool_calls' && toolCallBuf && hasToolCallMarker(toolCallBuf)) { + const parsedCalls = parseToolCalls(toolCallBuf); + if (parsedCalls.length > 0) logRespObj.tool_calls = parsedCalls.map(tc => ({ name: tc.name, arguments: tc.arguments })); + } + if (lastUsage) logRespObj.usage = { prompt_tokens: lastUsage.promptTokens, completion_tokens: lastUsage.completionTokens }; + responseBodyStr = JSON.stringify(logRespObj); + await s.write(`data: ${JSON.stringify({ id: responseId, object: 'chat.completion.chunk', created, model: mimoModel, system_fingerprint: 'fp_mimo_' + created, choices: [{ index: 0, delta: {}, finish_reason: finishReason }], usage: usageChunk })}\n\n`); await s.write('data: [DONE]\n\n'); console.log('[STREAM] ✓ Completed:', { chunks: chunkCount, finishReason, tokens: lastUsage?.totalTokens || 0, duration: Date.now() - startTime + 'ms' }); } @@ -474,12 +785,12 @@ export function registerOpenAI(app: Hono) { if (!isAborted) { try { await s.write(`data: ${JSON.stringify({ error: { message: String(err), type: 'api_error' } })}\n\n`); await s.write('data: [DONE]\n\n'); } catch {} } - logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'error', error: String(err), duration_ms: Date.now() - startTime }); + logRequest({ account_id: account.id, session_id: session.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'error', error: String(err), duration_ms: Date.now() - startTime, request_body: requestBody, response_body: responseBodyStr }); loggedError = true; } finally { decrementActive(account.id); if (!loggedError) { - logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime }); + logRequest({ account_id: account.id, session_id: session.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime, request_body: requestBody, response_body: responseBodyStr }); if (lastUsage) { updateSessionTokens(session.id, lastUsage.promptTokens); } @@ -497,7 +808,8 @@ export function registerOpenAI(app: Hono) { } fullText = processThinkContent(fullText, config.thinkMode); - logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime }); + const nonStreamRespBody = JSON.stringify({ content: sanitizeOutput(fullText), usage: lastUsage ? { prompt_tokens: lastUsage.promptTokens, completion_tokens: lastUsage.completionTokens } : undefined }); + logRequest({ account_id: account.id, session_id: session.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: lastUsage, status: 'success', duration_ms: Date.now() - startTime, request_body: requestBody, response_body: nonStreamRespBody }); // 更新会话 token 统计 if (lastUsage) { updateSessionTokens(session.id, lastUsage.promptTokens); @@ -528,16 +840,20 @@ export function registerOpenAI(app: Hono) { } return c.json({ id: responseId, object: 'chat.completion', created, model: mimoModel, system_fingerprint: `fp_mimo_${created}`, - choices: [{ index: 0, message: { role: 'assistant', content: fullText }, finish_reason: 'stop' }], + choices: [{ index: 0, message: { role: 'assistant', content: sanitizeOutput(fullText) }, finish_reason: 'stop' }], usage: usageObj, }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); + if (err instanceof OpenAIRequestError) { + logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: null, status: 'error', error: msg, duration_ms: Date.now() - startTime, request_body: requestBody }); + return c.json({ error: { message: msg, type: 'invalid_request_error' } }, err.status as any); + } handleAccountError(account, msg); - logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: null, status: 'error', error: msg, duration_ms: Date.now() - startTime }); + logRequest({ account_id: account.id, api_key_id: apiKeyRecord.id, model: mimoModel, usage: null, status: 'error', error: msg, duration_ms: Date.now() - startTime, request_body: requestBody }); return c.json({ error: { message: msg, type: 'api_error' } }, 502); } finally { - if (!isStream) decrementActive(account.id); + if (!streamStarted) decrementActive(account.id); } }); } diff --git a/src/admin/routes.ts b/src/admin/routes.ts old mode 100644 new mode 100755 index 9b5fdce..c101248 --- a/src/admin/routes.ts +++ b/src/admin/routes.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { config, saveSetting } from '../config.js'; +import { config } from '../config.js'; import { listAccounts, createAccount, getAccountById, updateAccount, deleteAccount, parseCurl, @@ -10,34 +10,12 @@ import { updateApiKey, deleteApiKey } from '../api-keys.js'; import { listSessions, deleteSession } from '../mimo/session.js'; -import { loadConfig, saveConfig, loadLogs, loadAccountData, RequestLogRecord, LOGS_PATH } from '../db.js'; -import { callMimo } from '../mimo/client.js'; +import { db } from '../db.js'; +import { callMimo, fetchBotConfig } from '../mimo/client.js'; +import { validateMimoProxyUrl } from '../mimo/proxy-agent.js'; import { randomUUID } from 'crypto'; -import { readdirSync, existsSync } from 'fs'; -import path from 'path'; - -function getAllLogs(): RequestLogRecord[] { - if (!existsSync(LOGS_PATH)) return []; - const files = readdirSync(LOGS_PATH).filter((f: string) => f.endsWith('.json')).sort(); - const allLogs: RequestLogRecord[] = []; - for (const file of files) { - const date = file.replace('.json', ''); - allLogs.push(...loadLogs(date)); - } - return allLogs; -} - -function getLogsByDateRange(startDate: string, endDate: string): RequestLogRecord[] { - if (!existsSync(LOGS_PATH)) return []; - const files = readdirSync(LOGS_PATH).filter((f: string) => f.endsWith('.json')).sort(); - const allLogs: RequestLogRecord[] = []; - for (const file of files) { - const date = file.replace('.json', ''); - if (date >= startDate && date <= endDate) { - allLogs.push(...loadLogs(date)); - } - } - return allLogs; +function saveSetting(key: string, value: string) { + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value); } async function adminAuth(c: Parameters[1]>[0], next: () => Promise): Promise { @@ -58,26 +36,19 @@ export function registerAdmin(app: Hono) { const limit = Math.min(Math.max(1, Number(c.req.query('limit') ?? 10)), 100); const offset = (page - 1) * limit; - const allAccounts = listAccounts(); - const total = allAccounts.length; - - const allLogs = getAllLogs(); - const logsByAccount = new Map(); - for (const log of allLogs) { - if (!logsByAccount.has(log.account_id)) logsByAccount.set(log.account_id, []); - logsByAccount.get(log.account_id)!.push(log); - } - - const accounts = allAccounts.slice(offset, offset + limit).map(a => { - const logs = logsByAccount.get(a.id) || []; - return { - ...a, - total_requests: logs.length, - total_prompt_tokens: logs.reduce((sum, l) => sum + (l.prompt_tokens || 0), 0), - total_completion_tokens: logs.reduce((sum, l) => sum + (l.completion_tokens || 0), 0), - }; - }); - + const total = (db.prepare('SELECT COUNT(*) as cnt FROM accounts').get() as { cnt: number }).cnt; + const accounts = db.prepare(` + SELECT a.id, a.alias, a.user_id, a.service_token, a.ph_token, a.api_key, + a.is_active, a.active_requests, a.created_at, + COALESCE(COUNT(l.id), 0) as total_requests, + COALESCE(SUM(l.prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(l.completion_tokens), 0) as total_completion_tokens + FROM accounts a + LEFT JOIN request_logs l ON a.id = l.account_id + GROUP BY a.id + ORDER BY a.created_at DESC + LIMIT ? OFFSET ? + `).all(limit, offset); return c.json({ accounts, total, page, limit }); }); @@ -145,15 +116,28 @@ export function registerAdmin(app: Hono) { return c.json(listSessions()); }); + admin.get('/sessions/:id', (c) => { + const id = c.req.param('id'); + const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Record | undefined; + if (!session) return c.json({ error: 'Not found' }, 404); + // 获取该会话关联的请求日志(最近 50 条) + const logs = db.prepare( + 'SELECT * FROM request_logs WHERE session_id = ? ORDER BY created_at DESC LIMIT 50' + ).all(id); + // 获取关联账号信息 + const account = db.prepare( + 'SELECT id, alias, user_id FROM accounts WHERE id = ?' + ).get(session.account_id as string) as Record | undefined; + return c.json({ ...session, logs, account }); + }); + admin.delete('/sessions/:id', (c) => { deleteSession(c.req.param('id')); return c.json({ message: 'Deleted' }); }); admin.delete('/sessions', (c) => { - const configData = loadConfig(); - configData.sessions = []; - saveConfig(configData); + db.prepare('DELETE FROM sessions').run(); return c.json({ message: 'All sessions deleted' }); }); @@ -161,60 +145,63 @@ export function registerAdmin(app: Hono) { admin.get('/logs', (c) => { const accountId = c.req.query('account_id'); const status = c.req.query('status'); - const page = Number(c.req.query('page') ?? 1); - const limit = Math.min(Number(c.req.query('limit') ?? 50), 200); + const endpoint = c.req.query('endpoint'); + const page = Math.max(1, Number(c.req.query('page') ?? 1)); + const limit = Math.min(Math.max(1, Number(c.req.query('limit') ?? 50)), 200); const offset = (page - 1) * limit; - let allLogs = getAllLogs(); - - if (accountId) allLogs = allLogs.filter(l => l.account_id === accountId); - if (status) allLogs = allLogs.filter(l => l.status === status); - - allLogs.sort((a, b) => b.created_at.localeCompare(a.created_at)); - const total = allLogs.length; - const logs = allLogs.slice(offset, offset + limit); - + let countSql = 'SELECT COUNT(*) as cnt FROM request_logs WHERE 1=1'; + let sql = 'SELECT * FROM request_logs WHERE 1=1'; + const params: unknown[] = []; + const countParams: unknown[] = []; + if (accountId) { sql += ' AND account_id = ?'; params.push(accountId); countSql += ' AND account_id = ?'; countParams.push(accountId); } + if (status) { sql += ' AND status = ?'; params.push(status); countSql += ' AND status = ?'; countParams.push(status); } + if (endpoint) { sql += ' AND endpoint = ?'; params.push(endpoint); countSql += ' AND endpoint = ?'; countParams.push(endpoint); } + sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const logs = db.prepare(sql).all(...params); + const total = (db.prepare(countSql).get(...countParams) as { cnt: number }).cnt; return c.json({ logs, total, page, limit }); }); + admin.get('/logs/:id', (c) => { + const id = c.req.param('id'); + const log = db.prepare('SELECT * FROM request_logs WHERE id = ?').get(id); + if (!log) return c.json({ error: 'Not found' }, 404); + return c.json(log); + }); + // --- Stats --- admin.get('/stats', (c) => { const page = Math.max(1, Number(c.req.query('page') ?? 1)); const limit = Math.min(Math.max(1, Number(c.req.query('limit') ?? 10)), 100); const offset = (page - 1) * limit; - const allAccounts = listAccounts(); - const totalAccounts = allAccounts.length; - - const allLogs = getAllLogs(); - const logsByAccount = new Map(); - for (const log of allLogs) { - if (!logsByAccount.has(log.account_id)) logsByAccount.set(log.account_id, []); - logsByAccount.get(log.account_id)!.push(log); - } - - const accounts = allAccounts.slice(offset, offset + limit).map(a => { - const logs = logsByAccount.get(a.id) || []; - return { - id: a.id, - alias: a.alias, - api_key: a.api_key, - is_active: a.is_active, - active_requests: a.active_requests, - total_prompt_tokens: logs.reduce((sum, l) => sum + (l.prompt_tokens || 0), 0), - total_completion_tokens: logs.reduce((sum, l) => sum + (l.completion_tokens || 0), 0), - total_requests: logs.length, - }; - }); - - const totalPromptTokens = allLogs.reduce((sum, l) => sum + (l.prompt_tokens || 0), 0); - const totalCompletionTokens = allLogs.reduce((sum, l) => sum + (l.completion_tokens || 0), 0); + const totalAccounts = (db.prepare('SELECT COUNT(*) as cnt FROM accounts').get() as { cnt: number }).cnt; + const accounts = db.prepare(` + SELECT a.id, a.alias, a.api_key, a.is_active, a.active_requests, + COALESCE(SUM(l.prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(l.completion_tokens), 0) as total_completion_tokens, + COUNT(l.id) as total_requests + FROM accounts a + LEFT JOIN request_logs l ON a.id = l.account_id + GROUP BY a.id + LIMIT ? OFFSET ? + `).all(limit, offset); + + // 全量汇总(不受分页影响) + const totals = db.prepare(` + SELECT COALESCE(SUM(l.prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(l.completion_tokens), 0) as total_completion_tokens + FROM request_logs l + `).get() as { total_prompt_tokens: number; total_completion_tokens: number }; return c.json({ accounts, maxConcurrent: config.maxConcurrentPerAccount, totalAccounts, page, limit, - totalPromptTokens, - totalCompletionTokens, + totalPromptTokens: totals.total_prompt_tokens, + totalCompletionTokens: totals.total_completion_tokens, }); }); @@ -223,146 +210,113 @@ export function registerAdmin(app: Hono) { const limit = Math.min(Math.max(1, Number(c.req.query('limit') ?? 10)), 100); const offset = (page - 1) * limit; - const configData = loadConfig(); - const allApiKeys = configData.api_keys || []; - const total = allApiKeys.length; - - const allLogs = getAllLogs(); - const logsByKey = new Map(); - for (const log of allLogs) { - if (log.api_key_id) { - if (!logsByKey.has(log.api_key_id)) logsByKey.set(log.api_key_id, []); - logsByKey.get(log.api_key_id)!.push(log); - } - } - - const apiKeys = allApiKeys.slice(offset, offset + limit).map(k => { - const logs = logsByKey.get(k.id) || []; - return { - ...k, - total_requests: logs.length, - total_prompt_tokens: logs.reduce((sum, l) => sum + (l.prompt_tokens || 0), 0), - total_completion_tokens: logs.reduce((sum, l) => sum + (l.completion_tokens || 0), 0), - }; - }); - + const total = (db.prepare('SELECT COUNT(*) as cnt FROM api_keys').get() as { cnt: number }).cnt; + const apiKeys = db.prepare(` + SELECT k.id, k.key, k.name, k.is_active, k.request_count, k.last_used_at, + COALESCE(COUNT(l.id), 0) as total_requests, + COALESCE(SUM(l.prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(l.completion_tokens), 0) as total_completion_tokens + FROM api_keys k + LEFT JOIN request_logs l ON k.id = l.api_key_id + GROUP BY k.id + ORDER BY k.created_at DESC + LIMIT ? OFFSET ? + `).all(limit, offset); return c.json({ apiKeys, total, page, limit }); }); admin.get('/stats/overview', (c) => { - const allLogs = getAllLogs(); - const today = new Date().toISOString().slice(0, 10).replace(/-/g, ''); - const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10).replace(/-/g, ''); - - const todayLogs = allLogs.filter(l => l.created_at.replace(/[-:T ]/g, '').slice(0, 8) === today); - const yesterdayLogs = allLogs.filter(l => l.created_at.replace(/[-:T ]/g, '').slice(0, 8) === yesterday); - - const todayStats = { - requests: todayLogs.length, - tokens: todayLogs.reduce((sum, l) => sum + (l.prompt_tokens || 0) + (l.completion_tokens || 0) + (l.reasoning_tokens || 0), 0), - success_count: todayLogs.filter(l => l.status === 'success').length, - avg_latency: todayLogs.filter(l => l.status === 'success').length > 0 - ? todayLogs.filter(l => l.status === 'success').reduce((sum, l) => sum + l.duration_ms, 0) / todayLogs.filter(l => l.status === 'success').length - : 0, - }; - - const yesterdayStats = { - requests: yesterdayLogs.length, - tokens: yesterdayLogs.reduce((sum, l) => sum + (l.prompt_tokens || 0) + (l.completion_tokens || 0) + (l.reasoning_tokens || 0), 0), - }; - - const thirtyDaysAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10).replace(/-/g, ''); - const recentLogs = allLogs.filter(l => { - const date = l.created_at.replace(/[-:T ]/g, '').slice(0, 8); - return date >= thirtyDaysAgo; - }); - - const dailyMap = new Map(); - for (const log of recentLogs) { - const date = log.created_at.slice(0, 10); - if (!dailyMap.has(date)) dailyMap.set(date, { input_tokens: 0, output_tokens: 0, requests: 0 }); - const day = dailyMap.get(date)!; - day.input_tokens += log.prompt_tokens || 0; - day.output_tokens += log.completion_tokens || 0; - day.requests += 1; - } - const dailyTrend = Array.from(dailyMap.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, stats]) => ({ date, ...stats })); - - const endpointMap = new Map(); - for (const log of allLogs) { - if (!endpointMap.has(log.endpoint)) endpointMap.set(log.endpoint, { requests: 0, tokens: 0 }); - const ep = endpointMap.get(log.endpoint)!; - ep.requests += 1; - ep.tokens += (log.prompt_tokens || 0) + (log.completion_tokens || 0); - } - const endpointDist = Array.from(endpointMap.entries()).map(([endpoint, stats]) => ({ endpoint, ...stats })); - - const modelMap = new Map(); - for (const log of allLogs) { - if (log.model) { - if (!modelMap.has(log.model)) modelMap.set(log.model, { requests: 0, tokens: 0 }); - const m = modelMap.get(log.model)!; - m.requests += 1; - m.tokens += (log.prompt_tokens || 0) + (log.completion_tokens || 0); - } - } - const modelDist = Array.from(modelMap.entries()) - .map(([model, stats]) => ({ model, ...stats })) - .sort((a, b) => b.tokens - a.tokens) - .slice(0, 5); - - const allAccounts = listAccounts(); - const accountMap = new Map(); - for (const a of allAccounts) accountMap.set(a.id, a.alias || a.user_id); - - const accountRankMap = new Map(); - for (const log of allLogs) { - if (!accountRankMap.has(log.account_id)) accountRankMap.set(log.account_id, { tokens: 0, requests: 0 }); - const ar = accountRankMap.get(log.account_id)!; - ar.tokens += (log.prompt_tokens || 0) + (log.completion_tokens || 0); - ar.requests += 1; - } - const accountRanking = Array.from(accountRankMap.entries()) - .map(([id, stats]) => ({ name: accountMap.get(id) || id, ...stats })) - .sort((a, b) => b.tokens - a.tokens) - .slice(0, 10); - - const configData = loadConfig(); - const apiKeyMap = new Map(); - for (const k of configData.api_keys || []) apiKeyMap.set(k.id, k.name || k.key); - - const apiKeyRankMap = new Map(); - for (const log of allLogs) { - if (log.api_key_id) { - if (!apiKeyRankMap.has(log.api_key_id)) apiKeyRankMap.set(log.api_key_id, { tokens: 0, requests: 0 }); - const ak = apiKeyRankMap.get(log.api_key_id)!; - ak.tokens += (log.prompt_tokens || 0) + (log.completion_tokens || 0); - ak.requests += 1; - } - } - const apiKeyRanking = Array.from(apiKeyRankMap.entries()) - .map(([id, stats]) => ({ name: apiKeyMap.get(id) || id, ...stats })) - .sort((a, b) => b.tokens - a.tokens) - .slice(0, 10); - - const hourlyMap = new Map(); - for (let h = 0; h < 24; h++) hourlyMap.set(h, 0); - for (const log of todayLogs) { - const hour = parseInt(log.created_at.slice(11, 13) || '0'); - hourlyMap.set(hour, (hourlyMap.get(hour) || 0) + 1); - } - const hourlyDist = Array.from(hourlyMap.entries()).map(([hour, requests]) => ({ hour, requests })); + // 1. 今日概览 + const today = db.prepare(` + SELECT COUNT(*) as requests, + COALESCE(SUM(prompt_tokens + completion_tokens + reasoning_tokens), 0) as tokens, + COALESCE(SUM(CASE WHEN status='success' THEN 1 ELSE 0 END), 0) as success_count, + COALESCE(AVG(CASE WHEN status='success' THEN duration_ms END), 0) as avg_latency + FROM request_logs WHERE date(created_at) = date('now') + `).get() as any; + + const yesterday = db.prepare(` + SELECT COUNT(*) as requests, + COALESCE(SUM(prompt_tokens + completion_tokens + reasoning_tokens), 0) as tokens + FROM request_logs WHERE date(created_at) = date('now', '-1 day') + `).get() as any; + + // 2. 每日趋势(最近 30 天) + const dailyTrend = db.prepare(` + SELECT date(created_at) as date, + COALESCE(SUM(prompt_tokens), 0) as input_tokens, + COALESCE(SUM(completion_tokens), 0) as output_tokens, + COUNT(*) as requests + FROM request_logs + WHERE created_at >= date('now', '-30 days') + GROUP BY date(created_at) + ORDER BY date ASC + `).all(); + + // 3. 端点分布 + const endpointDist = db.prepare(` + SELECT endpoint, + COUNT(*) as requests, + COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens + FROM request_logs + GROUP BY endpoint + `).all(); + + // 4. 模型分布(Top 5) + const modelDist = db.prepare(` + SELECT model, + COUNT(*) as requests, + COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens + FROM request_logs + WHERE model IS NOT NULL AND model != '' + GROUP BY model + ORDER BY tokens DESC + LIMIT 5 + `).all(); + + // 5. 账号排行(Top 10) + const accountRanking = db.prepare(` + SELECT COALESCE(a.alias, a.user_id) as name, + COALESCE(SUM(l.prompt_tokens + l.completion_tokens), 0) as tokens, + COUNT(l.id) as requests + FROM request_logs l + LEFT JOIN accounts a ON l.account_id = a.id + GROUP BY l.account_id + ORDER BY tokens DESC + LIMIT 10 + `).all(); + + // 6. API Key 排行(Top 10) + const apiKeyRanking = db.prepare(` + SELECT COALESCE(k.name, k.key) as name, + COALESCE(SUM(l.prompt_tokens + l.completion_tokens), 0) as tokens, + COUNT(l.id) as requests + FROM request_logs l + LEFT JOIN api_keys k ON l.api_key_id = k.id + WHERE l.api_key_id IS NOT NULL + GROUP BY l.api_key_id + ORDER BY tokens DESC + LIMIT 10 + `).all(); + + // 7. 每小时分布(今天) + const hourlyDist = db.prepare(` + SELECT CAST(strftime('%H', created_at) AS INTEGER) as hour, + COUNT(*) as requests + FROM request_logs + WHERE date(created_at) = date('now') + GROUP BY hour + ORDER BY hour + `).all(); return c.json({ today: { - requests: todayStats.requests, - tokens: todayStats.tokens, - successRate: todayStats.requests > 0 ? Math.round((todayStats.success_count / todayStats.requests) * 1000) / 10 : 100, - avgLatency: Math.round(todayStats.avg_latency), + requests: today.requests, + tokens: today.tokens, + successRate: today.requests > 0 ? Math.round((today.success_count / today.requests) * 1000) / 10 : 100, + avgLatency: Math.round(today.avg_latency), }, - yesterday: { requests: yesterdayStats.requests, tokens: yesterdayStats.tokens }, + yesterday: { requests: yesterday.requests, tokens: yesterday.tokens }, dailyTrend, endpointDist, modelDist, @@ -405,14 +359,13 @@ export function registerAdmin(app: Hono) { const apiKey = getApiKeyById(id); if (!apiKey) return c.json({ error: 'Not found' }, 404); - const allLogs = getAllLogs(); - const keyLogs = allLogs.filter(l => l.api_key_id === id); - - const stats = { - total_requests: keyLogs.length, - total_prompt_tokens: keyLogs.reduce((sum, l) => sum + (l.prompt_tokens || 0), 0), - total_completion_tokens: keyLogs.reduce((sum, l) => sum + (l.completion_tokens || 0), 0), - }; + const stats = db.prepare(` + SELECT COUNT(*) as total_requests, + COALESCE(SUM(prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(completion_tokens), 0) as total_completion_tokens + FROM request_logs + WHERE api_key_id = ? + `).get(id); return c.json({ ...apiKey, stats }); }); @@ -428,6 +381,7 @@ export function registerAdmin(app: Hono) { thinkMode: config.thinkMode, sessionTtlDays: config.sessionTtlDays, sessionIsolation: config.sessionIsolation, + mimoProxy: config.mimoProxy, }); }); @@ -451,9 +405,52 @@ export function registerAdmin(app: Hono) { (config as Record).sessionIsolation = body.sessionIsolation; saveSetting('sessionIsolation', body.sessionIsolation); } + if (body.mimoProxy !== undefined) { + if (typeof body.mimoProxy !== 'string') { + return c.json({ error: 'mimoProxy must be a string' }, 400); + } + const mimoProxy = body.mimoProxy.trim(); + const validationError = validateMimoProxyUrl(mimoProxy); + if (validationError) { + return c.json({ error: validationError }, 400); + } + (config as Record).mimoProxy = mimoProxy; + saveSetting('mimoProxy', mimoProxy); + } return c.json({ message: 'Config updated' }); }); + admin.post('/mimo-proxy/test', async (c) => { + const proxy = config.mimoProxy.trim(); + if (!proxy) { + return c.json({ success: false, error: '未配置 MiMo 专用代理' }, 400); + } + const validationError = validateMimoProxyUrl(proxy); + if (validationError) { + return c.json({ success: false, error: validationError }, 400); + } + + const start = Date.now(); + try { + const botConfig = await fetchBotConfig(true); + const models = botConfig.modelConfigListNg?.filter(m => m.pageType === 'chat').length ?? 0; + return c.json({ + success: true, + latency: Date.now() - start, + proxy, + models, + message: 'MiMo 代理连通', + }); + } catch (e) { + return c.json({ + success: false, + latency: Date.now() - start, + proxy, + error: `MiMo 代理测试失败: ${e instanceof Error ? e.message : String(e)}`, + }); + } + }); + admin.patch('/admin-key', async (c) => { const body = await c.req.json(); if (!body.newKey || typeof body.newKey !== 'string' || body.newKey.trim().length === 0) { diff --git a/src/api-keys.ts b/src/api-keys.ts old mode 100644 new mode 100755 index 35be58c..b86bf03 --- a/src/api-keys.ts +++ b/src/api-keys.ts @@ -1,4 +1,4 @@ -import { loadConfig, saveConfig, ApiKeyRecord } from './db.js'; +import { db } from './db.js'; import { randomUUID } from 'crypto'; export interface ApiKey { @@ -11,15 +11,20 @@ export interface ApiKey { request_count: number; } +/** + * 创建新的 API 密钥 + */ export function createApiKey(name?: string, customKey?: string): ApiKey { const id = randomUUID(); const key = customKey || 'sk-' + randomUUID().replace(/-/g, ''); const created_at = new Date().toISOString(); - const configData = loadConfig(); - if (!configData.api_keys) configData.api_keys = []; + db.prepare( + `INSERT INTO api_keys (id, key, name, created_at) + VALUES (?, ?, ?, ?)` + ).run(id, key, name ?? null, created_at); - const newKey: ApiKeyRecord = { + return { id, key, name: name ?? null, @@ -28,57 +33,67 @@ export function createApiKey(name?: string, customKey?: string): ApiKey { last_used_at: null, request_count: 0, }; - - configData.api_keys.push(newKey); - saveConfig(configData); - - return newKey; } +/** + * 列出所有 API 密钥 + */ export function listApiKeys(): ApiKey[] { - const configData = loadConfig(); - return configData.api_keys || []; + return db.prepare('SELECT * FROM api_keys ORDER BY created_at DESC').all() as ApiKey[]; } +/** + * 根据 ID 获取 API 密钥 + */ export function getApiKeyById(id: string): ApiKey | undefined { - const configData = loadConfig(); - return configData.api_keys?.find(k => k.id === id); + return db.prepare('SELECT * FROM api_keys WHERE id = ?').get(id) as ApiKey | undefined; } +/** + * 验证 API 密钥是否有效 + * @returns 如果有效返回密钥记录,否则返回 undefined + */ export function validateApiKey(key: string): ApiKey | undefined { - const configData = loadConfig(); - return configData.api_keys?.find(k => k.key === key && k.is_active === 1); + return db.prepare('SELECT * FROM api_keys WHERE key = ? AND is_active = 1').get(key) as ApiKey | undefined; } +/** + * 更新 API 密钥 + */ export function updateApiKey(id: string, data: { name?: string; is_active?: number }) { - const configData = loadConfig(); - if (!configData.api_keys) return; - - const apiKey = configData.api_keys.find(k => k.id === id); - if (!apiKey) return; - - if (data.name !== undefined) apiKey.name = data.name; - if (data.is_active !== undefined) apiKey.is_active = data.is_active; - - saveConfig(configData); + const fields: string[] = []; + const values: unknown[] = []; + + if (data.name !== undefined) { + fields.push('name = ?'); + values.push(data.name); + } + if (data.is_active !== undefined) { + fields.push('is_active = ?'); + values.push(data.is_active); + } + + if (!fields.length) return; + + values.push(id); + db.prepare(`UPDATE api_keys SET ${fields.join(', ')} WHERE id = ?`).run(...values); } +/** + * 删除 API 密钥 + */ export function deleteApiKey(id: string) { - const configData = loadConfig(); - if (!configData.api_keys) return; - - configData.api_keys = configData.api_keys.filter(k => k.id !== id); - saveConfig(configData); + db.prepare('DELETE FROM api_keys WHERE id = ?').run(id); } +/** + * 记录 API 密钥使用情况 + */ export function recordApiKeyUsage(id: string) { - const configData = loadConfig(); - if (!configData.api_keys) return; - - const apiKey = configData.api_keys.find(k => k.id === id); - if (!apiKey) return; - - apiKey.last_used_at = new Date().toISOString(); - apiKey.request_count += 1; - saveConfig(configData); + const now = new Date().toISOString(); + db.prepare( + `UPDATE api_keys + SET last_used_at = ?, request_count = request_count + 1 + WHERE id = ?` + ).run(now, id); } diff --git a/src/config.ts b/src/config.ts old mode 100644 new mode 100755 index cc8fac9..607b4fb --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import { loadConfig as loadJsonConfig, saveConfig, ConfigData } from './db.js'; +import { db } from './db.js'; const DEFAULTS = { port: 8080, @@ -10,43 +10,37 @@ const DEFAULTS = { thinkMode: 'separate' as 'passthrough' | 'strip' | 'separate', sessionTtlDays: 7, sessionIsolation: 'auto' as 'manual' | 'auto' | 'per-request', + mimoProxy: '', }; export const config: typeof DEFAULTS = { ...DEFAULTS }; export function loadConfig() { - const data = loadJsonConfig(); + const rows = db.prepare('SELECT key, value FROM settings').all() as Array<{ key: string; value: string }>; + const map = new Map(rows.map(r => [r.key, r.value])); const numKeys: Array = [ 'port', 'maxReplayMessages', 'maxQueryChars', 'contextResetThreshold', 'maxConcurrentPerAccount', 'sessionTtlDays', ]; for (const key of numKeys) { - if (data[key] !== undefined) { - const v = Number(data[key]); + if (map.has(key)) { + const v = Number(map.get(key)); if (!isNaN(v)) (config as Record)[key] = v; } } - if (data.adminKey) config.adminKey = data.adminKey; - if (data.thinkMode && ['passthrough', 'strip', 'separate'].includes(data.thinkMode)) { - config.thinkMode = data.thinkMode; + if (map.has('adminKey')) config.adminKey = map.get('adminKey')!; + const mimoProxyVal = map.get('mimoProxy'); + if (mimoProxyVal != null) config.mimoProxy = mimoProxyVal.trim(); + if (map.has('thinkMode') && ['passthrough', 'strip', 'separate'].includes(map.get('thinkMode')!)) { + config.thinkMode = map.get('thinkMode') as typeof config.thinkMode; } - if (data.sessionIsolation && ['manual', 'auto', 'per-request'].includes(data.sessionIsolation)) { - config.sessionIsolation = data.sessionIsolation; + if (map.has('sessionIsolation') && ['manual', 'auto', 'per-request'].includes(map.get('sessionIsolation')!)) { + config.sessionIsolation = map.get('sessionIsolation') as typeof config.sessionIsolation; } - console.log('[CONFIG] Loaded from JSON:', { - port: config.port, - adminKey: config.adminKey, - maxReplayMessages: config.maxReplayMessages, - maxQueryChars: config.maxQueryChars, - contextResetThreshold: config.contextResetThreshold, - maxConcurrentPerAccount: config.maxConcurrentPerAccount, - thinkMode: config.thinkMode, - sessionTtlDays: config.sessionTtlDays, - sessionIsolation: config.sessionIsolation, - }); + console.log('[CONFIG] Loaded from database:', Object.fromEntries(map)); } export const DEBUG = !!(process.env.DEBUG ?? process.env.NODE_ENV !== 'production'); @@ -55,7 +49,14 @@ export function debugLog(...args: unknown[]) { } export function saveSetting(key: string, value: string) { - const data = loadJsonConfig(); - (data as Record)[key] = value; - saveConfig(data); + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value); + // Also apply to in-memory config immediately + if (key in config) { + if (typeof (config as Record)[key] === 'number') { + const v = Number(value); + if (!isNaN(v)) (config as Record)[key] = v; + } else { + (config as Record)[key] = value; + } + } } diff --git a/src/db.ts b/src/db.ts old mode 100644 new mode 100755 index ff77395..c2e5bd6 --- a/src/db.ts +++ b/src/db.ts @@ -1,149 +1,160 @@ -import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import Database from 'better-sqlite3'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DATA_DIR = path.join(__dirname, '..', 'data'); -const LOGS_DIR = path.join(__dirname, '..', 'logs'); +const DB_DIR = path.join(__dirname, '..', 'dbdata'); +import { mkdirSync } from 'fs'; +mkdirSync(DB_DIR, { recursive: true }); +const DB_PATH = path.join(DB_DIR, 'mimo-proxy.db'); +export const db = new Database(DB_PATH); -mkdirSync(DATA_DIR, { recursive: true }); -mkdirSync(LOGS_DIR, { recursive: true }); - -export const CONFIG_PATH = path.join(DATA_DIR, 'config.json'); -export const ACCOUNT_PATH = path.join(DATA_DIR, 'account.json'); -export const LOGS_PATH = LOGS_DIR; - -export interface ConfigData { - port?: number; - adminKey?: string; - maxReplayMessages?: number; - maxQueryChars?: number; - contextResetThreshold?: number; - maxConcurrentPerAccount?: number; - thinkMode?: 'passthrough' | 'strip' | 'separate'; - sessionTtlDays?: number; - sessionIsolation?: 'manual' | 'auto' | 'per-request'; - api_keys?: ApiKeyRecord[]; - sessions?: SessionRecord[]; -} - -export interface ApiKeyRecord { - id: string; - key: string; - name: string | null; - is_active: number; - created_at: string; - last_used_at: string | null; - request_count: number; -} - -export interface SessionRecord { - id: string; - account_id: string; - client_session_id: string; - conversation_id: string; - last_message_fingerprint: string; - cumulative_prompt_tokens: number; - is_expired: number; - created_at: string; - last_used_at: string; -} +export function initDb() { + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); + + db.exec(` + CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + alias TEXT, + service_token TEXT NOT NULL, + user_id TEXT NOT NULL, + ph_token TEXT NOT NULL, + api_key TEXT NOT NULL UNIQUE, + is_active INTEGER DEFAULT 1, + active_requests INTEGER DEFAULT 0, + request_count INTEGER DEFAULT 0, + created_at TEXT + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + client_session_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + last_message_fingerprint TEXT DEFAULT '', + cumulative_prompt_tokens INTEGER DEFAULT 0, + is_expired INTEGER DEFAULT 0, + created_at TEXT, + last_used_at TEXT, + UNIQUE(account_id, client_session_id) + ); + + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + key TEXT UNIQUE NOT NULL, + name TEXT, + is_active INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + last_used_at TEXT, + request_count INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS request_logs ( + id TEXT PRIMARY KEY, + account_id TEXT, + session_id TEXT, + api_key_id TEXT, + endpoint TEXT, + model TEXT, + prompt_tokens INTEGER, + completion_tokens INTEGER, + reasoning_tokens INTEGER, + duration_ms INTEGER, + status TEXT, + error TEXT, + created_at TEXT + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); + + // 启动时重置所有 active_requests,防止重启后计数卡住 + db.prepare('UPDATE accounts SET active_requests = 0').run(); + console.log('[DB] Reset all accounts active_requests to 0'); -export interface AccountRecord { - id: string; - alias: string | null; - service_token: string; - user_id: string; - ph_token: string; - api_key: string; - is_active: number; - active_requests: number; - request_count: number; - created_at: string; -} + // 迁移:添加 last_message_fingerprint 列(如果不存在) + try { + db.exec(`ALTER TABLE sessions ADD COLUMN last_message_fingerprint TEXT DEFAULT ''`); + console.log('[DB] Added last_message_fingerprint column to sessions table'); + } catch (err: any) { + if (!err.message.includes('duplicate column name')) { + console.error('[DB] Migration error:', err); + } + } -export interface AccountData { - accounts: AccountRecord[]; -} + // 迁移:添加 api_key_id 列到 request_logs(如果不存在) + try { + db.exec(`ALTER TABLE request_logs ADD COLUMN api_key_id TEXT`); + console.log('[DB] Added api_key_id column to request_logs table'); + } catch (err: any) { + if (!err.message.includes('duplicate column name')) { + console.error('[DB] Migration error:', err); + } + } -export interface RequestLogRecord { - id: string; - account_id: string; - session_id: string | null; - api_key_id: string | null; - endpoint: string; - model: string; - prompt_tokens: number | null; - completion_tokens: number | null; - reasoning_tokens: number | null; - duration_ms: number; - status: string; - error: string | null; - created_at: string; -} + // 迁移:accounts 增加 request_count 列(用于加权负载均衡) + try { + const accColNames = new Set((db.prepare("PRAGMA table_info(accounts)").all() as Array<{ name: string }>).map(c => c.name)); + if (!accColNames.has('request_count')) { + db.exec(`ALTER TABLE accounts ADD COLUMN request_count INTEGER DEFAULT 0`); + db.exec(`UPDATE accounts SET request_count = (SELECT COUNT(*) FROM request_logs WHERE request_logs.account_id = accounts.id)`); + console.log('[DB] Added request_count to accounts, backfilled from request_logs'); + } + } catch (err: any) { + if (!err.message.includes('duplicate column name')) { + console.error('[DB] Migration error:', err); + } + } -function readJsonFile(filePath: string, defaultValue: T): T { - if (!existsSync(filePath)) { - writeFileSync(filePath, JSON.stringify(defaultValue, null, 2), 'utf-8'); - return defaultValue; + // 迁移:添加 request_body 和 response_body 列到 request_logs(如果不存在) + try { + db.exec(`ALTER TABLE request_logs ADD COLUMN request_body TEXT`); + console.log('[DB] Added request_body column to request_logs table'); + } catch (err: any) { + if (!err.message.includes('duplicate column name')) { + console.error('[DB] Migration error:', err); + } } try { - const content = readFileSync(filePath, 'utf-8'); - return JSON.parse(content) as T; - } catch { - return defaultValue; + db.exec(`ALTER TABLE request_logs ADD COLUMN response_body TEXT`); + console.log('[DB] Added response_body column to request_logs table'); + } catch (err: any) { + if (!err.message.includes('duplicate column name')) { + console.error('[DB] Migration error:', err); + } } -} -function writeJsonFile(filePath: string, data: T): void { - writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); -} - -export function loadConfig(): ConfigData { - return readJsonFile(CONFIG_PATH, { - api_keys: [], - sessions: [] - }); -} - -export function saveConfig(data: ConfigData): void { - writeJsonFile(CONFIG_PATH, data); -} - -export function loadAccountData(): AccountData { - return readJsonFile(ACCOUNT_PATH, { accounts: [] }); -} - -export function saveAccountData(data: AccountData): void { - writeJsonFile(ACCOUNT_PATH, data); -} - -export function getLogFilePath(date?: string): string { - const d = date || new Date().toISOString().slice(0, 10).replace(/-/g, ''); - return path.join(LOGS_PATH, `${d}.json`); -} - -export function loadLogs(date?: string): RequestLogRecord[] { - const filePath = getLogFilePath(date); - return readJsonFile(filePath, []); -} - -export function appendLog(log: RequestLogRecord): void { - const filePath = getLogFilePath(); - const logs = loadLogs(); - logs.push(log); - writeJsonFile(filePath, logs); -} - -export function initDb() { - loadConfig(); - loadAccountData(); - console.log('[DB] JSON storage initialized'); - - const accountData = loadAccountData(); - for (const account of accountData.accounts) { - account.active_requests = 0; + // 清理旧的列(如果存在) + const columns = db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>; + const hasOldColumns = columns.some(c => c.name === 'last_messages_hash' || c.name === 'last_msg_count'); + + if (hasOldColumns) { + console.log('[DB] Migrating sessions table to remove old columns...'); + db.exec(` + CREATE TABLE sessions_new ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + client_session_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + last_message_fingerprint TEXT DEFAULT '', + cumulative_prompt_tokens INTEGER DEFAULT 0, + is_expired INTEGER DEFAULT 0, + created_at TEXT, + last_used_at TEXT, + UNIQUE(account_id, client_session_id) + ); + + INSERT INTO sessions_new (id, account_id, client_session_id, conversation_id, cumulative_prompt_tokens, is_expired, created_at, last_used_at) + SELECT id, account_id, client_session_id, conversation_id, cumulative_prompt_tokens, is_expired, created_at, last_used_at + FROM sessions; + + DROP TABLE sessions; + ALTER TABLE sessions_new RENAME TO sessions; + `); + console.log('[DB] Migration completed'); } - saveAccountData(accountData); - console.log('[DB] Reset all accounts active_requests to 0'); } diff --git a/src/index.ts b/src/index.ts old mode 100644 new mode 100755 diff --git a/src/middleware/request-handler.ts b/src/middleware/request-handler.ts old mode 100644 new mode 100755 index c735fa2..8be2ccf --- a/src/middleware/request-handler.ts +++ b/src/middleware/request-handler.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'crypto'; import { acquireAccount, decrementActive, markAccountInactive, Account } from '../accounts.js'; import { validateApiKey, recordApiKeyUsage, ApiKey } from '../api-keys.js'; import { config } from '../config.js'; -import { appendLog, RequestLogRecord } from '../db.js'; +import { db } from '../db.js'; import { MimoUsage } from '../mimo/client.js'; export interface RequestContext { @@ -33,6 +33,7 @@ export function acquireAccountForRequest(apiKeyRecord: ApiKey): { account: Accou export function logApiRequest(data: { account_id: string; + session_id?: string | null; api_key_id: string | null; endpoint: 'openai' | 'anthropic'; model: string; @@ -40,24 +41,19 @@ export function logApiRequest(data: { status: 'success' | 'error'; error?: string; duration_ms: number; + request_body?: string | null; + response_body?: string | null; }) { - const log: RequestLogRecord = { - id: randomUUID(), - account_id: data.account_id, - session_id: null, - api_key_id: data.api_key_id, - endpoint: data.endpoint, - model: data.model, - prompt_tokens: data.usage?.promptTokens ?? null, - completion_tokens: data.usage?.completionTokens ?? null, - reasoning_tokens: data.usage?.reasoningTokens ?? null, - duration_ms: data.duration_ms, - status: data.status, - error: data.error ?? null, - created_at: new Date().toLocaleString('sv-SE'), - }; - - appendLog(log); + db.prepare( + `INSERT INTO request_logs (id, account_id, session_id, api_key_id, endpoint, model, prompt_tokens, completion_tokens, reasoning_tokens, duration_ms, status, error, request_body, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + randomUUID(), data.account_id, data.session_id ?? null, data.api_key_id, data.endpoint, data.model, + data.usage?.promptTokens ?? null, data.usage?.completionTokens ?? null, + data.usage?.reasoningTokens ?? null, data.duration_ms, + data.status, data.error ?? null, data.request_body ?? null, data.response_body ?? null, + new Date().toLocaleString('sv-SE') + ); } export function handleAccountError(account: Account, errorMsg: string) { diff --git a/src/mimo/client.ts b/src/mimo/client.ts old mode 100644 new mode 100755 index 6bf8697..4feb4f3 --- a/src/mimo/client.ts +++ b/src/mimo/client.ts @@ -1,6 +1,9 @@ import { Account } from '../accounts.js'; import { randomUUID } from 'crypto'; import { MimoMedia } from './upload.js'; +import { getMimoProxyFetchOptions } from './proxy-agent.js'; + +const DEBUG = process.env.DEBUG_MIMO === '1' || process.env.DEBUG_MIMO === 'true'; export interface MimoUsage { promptTokens: number; @@ -38,30 +41,46 @@ let cachedBotConfig: BotConfig | null = null; let configCacheTime = 0; const CONFIG_CACHE_TTL = 5 * 60 * 1000; // 5 minutes -export async function fetchBotConfig(): Promise { +export async function fetchBotConfig(forceRefresh = false): Promise { const now = Date.now(); - if (cachedBotConfig && (now - configCacheTime) < CONFIG_CACHE_TTL) { + if (!forceRefresh && cachedBotConfig && (now - configCacheTime) < CONFIG_CACHE_TTL) { return cachedBotConfig; } - const resp = await fetch(CONFIG_URL, { - headers: { - 'Accept': '*/*', - 'Content-Type': 'application/json', - 'Origin': 'https://aistudio.xiaomimimo.com', - 'Referer': 'https://aistudio.xiaomimimo.com/', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - }, - }); + const maxAttempts = 3; + let lastError: any = null; - if (!resp.ok) { - throw new Error(`Failed to fetch bot config: ${resp.status}`); + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const resp = await fetch(CONFIG_URL, { + headers: { + 'Accept': '*/*', + 'Content-Type': 'application/json', + 'Origin': 'https://aistudio.xiaomimimo.com', + 'Referer': 'https://aistudio.xiaomimimo.com/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }, + ...getMimoProxyFetchOptions(), + } as RequestInit); + + if (!resp.ok) { + throw new Error(`Failed to fetch bot config: ${resp.status}`); + } + + const json = await resp.json() as BotConfigResponse; + cachedBotConfig = json.data; + configCacheTime = now; + return cachedBotConfig; + } catch (err) { + console.warn(`[Proxy] Attempt ${attempt} to fetch bot config failed:`, err); + lastError = err; + if (attempt < maxAttempts) { + await new Promise(r => setTimeout(r, 1000 * attempt)); + } + } } - const json = await resp.json() as BotConfigResponse; - cachedBotConfig = json.data; - configCacheTime = now; - return cachedBotConfig; + throw lastError || new Error('Failed to fetch bot config after retries'); } export function getChatModels(): string[] { @@ -100,34 +119,54 @@ export async function* callMimo( }); const url = `${API_URL}?xiaomichatbot_ph=${encodeURIComponent(account.ph_token)}`; - const resp = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Cookie': `serviceToken=${account.service_token}; userId=${account.user_id}; xiaomichatbot_ph=${account.ph_token}`, - 'Origin': 'https://aistudio.xiaomimimo.com', - 'Referer': 'https://aistudio.xiaomimimo.com/', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - 'x-timezone': 'Asia/Shanghai', - }, - body: JSON.stringify(body), - }); + const maxAttempts = 3; + let resp: Response | null = null; + let lastError: any = null; - if (!resp.ok) { - // 尝试读取错误响应内容 - let errorBody = ''; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - errorBody = await resp.text(); - console.error('[MIMO] ❌ Error response:', { - status: resp.status, - statusText: resp.statusText, - headers: Object.fromEntries(resp.headers.entries()), - body: errorBody - }); - } catch (e) { - console.error('[MIMO] ❌ Failed to read error body:', e); + resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cookie': `serviceToken=${account.service_token}; userId=${account.user_id}; xiaomichatbot_ph=${account.ph_token}`, + 'Origin': 'https://aistudio.xiaomimimo.com', + 'Referer': 'https://aistudio.xiaomimimo.com/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'x-timezone': 'Asia/Shanghai', + }, + body: JSON.stringify(body), + ...getMimoProxyFetchOptions(), + } as RequestInit); + + if (!resp.ok) { + // 尝试读取错误响应内容 + let errorBody = ''; + try { + errorBody = await resp.text(); + console.error('[MIMO] ❌ Error response:', { + status: resp.status, + statusText: resp.statusText, + headers: Object.fromEntries(resp.headers.entries()), + body: errorBody + }); + } catch (e) { + console.error('[MIMO] ❌ Failed to read error body:', e); + } + throw new Error(`MiMo error: ${resp.status} - ${errorBody.slice(0, 500)}`); + } + break; // Success + } catch (err) { + console.warn(`[Proxy] Attempt ${attempt} to call MiMo API failed:`, err); + lastError = err; + if (attempt < maxAttempts) { + await new Promise(r => setTimeout(r, 1000 * attempt)); + } } - throw new Error(`MiMo error: ${resp.status} - ${errorBody.slice(0, 500)}`); + } + + if (!resp || !resp.ok) { + throw lastError || new Error('Failed to call MiMo API after retries'); } if (!resp.body) throw new Error('No response body'); @@ -148,17 +187,17 @@ export async function* callMimo( for (const line of lines) { const trimmed = line.trim(); - console.log('[MIMO:RAW]', line); + if (DEBUG) console.log('[MIMO:RAW]', line); if (trimmed.startsWith('event:')) { event = trimmed.slice(6).trim(); } else if (trimmed.startsWith('data:')) { try { const data = JSON.parse(trimmed.slice(5).trim()); if (event === 'message') { - console.log('[MIMO:DEBUG] Message event data:', JSON.stringify(data).slice(0, 500)); + if (DEBUG) console.log('[MIMO:DEBUG] Message event data:', JSON.stringify(data).slice(0, 500)); yield { type: 'text', content: data.content ?? '' }; } else if (event === 'usage') { - console.log('[MIMO:DEBUG] Usage event data:', JSON.stringify(data)); + if (DEBUG) console.log('[MIMO:DEBUG] Usage event data:', JSON.stringify(data)); yield { type: 'usage', usage: { @@ -169,13 +208,13 @@ export async function* callMimo( }, }; } else if (event === 'finish') { - console.log('[MIMO:DEBUG] Finish event received'); + if (DEBUG) console.log('[MIMO:DEBUG] Finish event received'); yield { type: 'finish' }; } else if (event === 'dialogId') { - console.log('[MIMO:DEBUG] DialogId event:', data.content); + if (DEBUG) console.log('[MIMO:DEBUG] DialogId event:', data.content); yield { type: 'dialogId', content: data.content }; } else { - console.log('[MIMO:DEBUG] Unknown event type:', event, data); + if (DEBUG) console.log('[MIMO:DEBUG] Unknown event type:', event, data); } } catch (e) { console.error('[MIMO:ERROR] Failed to parse SSE data:', trimmed.slice(5).trim(), e); diff --git a/src/mimo/proxy-agent.ts b/src/mimo/proxy-agent.ts new file mode 100755 index 0000000..72b6b78 --- /dev/null +++ b/src/mimo/proxy-agent.ts @@ -0,0 +1,41 @@ +import { ProxyAgent } from 'undici'; +import { config } from '../config.js'; + +let cachedAgent: ProxyAgent | undefined; +let cachedProxyUrl = ''; + +export function validateMimoProxyUrl(proxyUrl: string): string | null { + const value = proxyUrl.trim(); + if (!value) return null; + + try { + const parsed = new URL(value); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return '代理地址仅支持 http:// 或 https://'; + } + if (!parsed.hostname || !parsed.port) { + return '代理地址需包含主机和端口'; + } + return null; + } catch { + return '代理地址格式无效'; + } +} + +export function getMimoProxyDispatcher(): ProxyAgent | undefined { + const proxyUrl = config.mimoProxy.trim(); + if (!proxyUrl) return undefined; + + if (!cachedAgent || cachedProxyUrl !== proxyUrl) { + console.log(`[Proxy] MiMo 专用代理: ${proxyUrl}`); + cachedAgent = new ProxyAgent(proxyUrl); + cachedProxyUrl = proxyUrl; + } + + return cachedAgent; +} + +export function getMimoProxyFetchOptions(): Record { + const dispatcher = getMimoProxyDispatcher(); + return dispatcher ? { dispatcher } : {}; +} diff --git a/src/mimo/serialize.ts b/src/mimo/serialize.ts old mode 100644 new mode 100755 index 6b6b632..0e26859 --- a/src/mimo/serialize.ts +++ b/src/mimo/serialize.ts @@ -88,6 +88,40 @@ function sanitizeContent(content: string | null, role: string): string { return cleaned; } +/** + * 清理 MiMo 响应输出中的系统内部标签 + * MiMo 有时会在响应中包含这些标签(如 等), + * 需要在返回给客户端前移除 + */ +export function sanitizeOutput(text: string): string { + if (!text) return text; + + let cleaned = text.replace(/\u0000/g, ''); + + // 移除完整的标签对(包括内容) + for (const tag of SYSTEM_TAGS) { + const regex = new RegExp(`<${tag}>[\s\S]*?`, 'g'); + cleaned = cleaned.replace(regex, ''); + } + + // 移除自闭合标签 + for (const tag of SYSTEM_TAGS) { + const regex = new RegExp(`<${tag}\s*/>`, 'g'); + cleaned = cleaned.replace(regex, ''); + } + + // 移除单独的开闭标签 + for (const tag of SYSTEM_TAGS) { + cleaned = cleaned.replace(new RegExp(`<${tag}>`, 'g'), ''); + cleaned = cleaned.replace(new RegExp(``, 'g'), ''); + } + + // 清理多余的空行 + cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim(); + + return cleaned; +} + /** * 格式化单条消息用于对话历史,保留工具调用上下文 */ @@ -99,7 +133,7 @@ function formatMessageForHistory(m: ChatMessage): string { return `${tc.function.name}(${args})`; }).join('\n'); const contentPart = m.content ? `\n${m.content}` : ''; - return `assistant: [Tool Calls]\n${callsStr}${contentPart}`; + return `assistant: [调用工具]\n${callsStr}${contentPart}`; } // tool 消息:显示工具结果(附带 tool_call_id 以关联调用) @@ -125,24 +159,34 @@ export function serializeMessages(messages: ChatMessage[]): string { const truncated = rest.slice(-config.maxReplayMessages); const msgs = [...system, ...truncated]; - const parts: string[] = []; - - const sysContent = system.map(m => m.content).join('\n'); - if (sysContent) parts.push(`[System Instruction]\n${sysContent}`); - const nonSystem = msgs.filter(m => m.role !== 'system'); const dialogHistory = nonSystem.slice(0, -1); const lastMsg = nonSystem[nonSystem.length - 1]; + const parts: string[] = []; + + let sysContent = system.map(m => m.content).join('\n'); + const hasChinese = /[\u4e00-\u9fa5]/.test(lastMsg?.content ?? ''); + if (hasChinese) { + const langPrompt = '【重要重要要求】无论系统指令使用的是何种语言,如果用户的当前问题是中文,请务必使用中文进行回答、总结和输出。'; + if (sysContent) { + sysContent += `\n\n${langPrompt}`; + } else { + sysContent = langPrompt; + } + } + + if (sysContent) parts.push(`[系统指令]\n${sysContent}`); + if (dialogHistory.length > 0) { const histStr = dialogHistory.map(m => formatMessageForHistory(m)).join('\n'); - parts.push(`[Conversation History]\n${histStr}`); + parts.push(`[对话历史]\n${histStr}`); } - if (lastMsg) parts.push(`[Current Query]\n${formatMessageForHistory(lastMsg)}`); + if (lastMsg) parts.push(`[当前问题]\n${formatMessageForHistory(lastMsg)}`); // 强制截断以确保不超过 MiMo 限制 - const sysStr = sysContent ? `[System Instruction]\n${sysContent}` : ''; + const sysStr = sysContent ? `[系统指令]\n${sysContent}` : ''; const restStr = parts.slice(sysContent ? 1 : 0).join('\n\n'); // 计算剩余可用空间 @@ -153,7 +197,7 @@ export function serializeMessages(messages: ChatMessage[]): string { if (sysStr.length > config.maxQueryChars * 0.6) { // System prompt 最多占 60% const maxSys = Math.floor(config.maxQueryChars * 0.6); - finalSysStr = sysStr.slice(0, maxSys) + '\n...(tool definitions truncated)'; + finalSysStr = sysStr.slice(0, maxSys) + '\n...(工具定义已截断)'; maxRest = config.maxQueryChars - finalSysStr.length - 2; console.log('[SERIALIZE] ⚠️ System prompt truncated:', { original: sysStr.length, @@ -164,7 +208,7 @@ export function serializeMessages(messages: ChatMessage[]): string { // 截断对话历史和当前消息 const truncatedRest = maxRest > 0 && restStr.length > maxRest - ? '...(history truncated)\n\n' + restStr.slice(-maxRest + 30) + ? '...(历史消息已截断)\n\n' + restStr.slice(-maxRest + 30) : restStr; const result = finalSysStr ? `${finalSysStr}\n\n${truncatedRest}` : truncatedRest; @@ -196,5 +240,5 @@ export function extractLastUserMessage(messages: ChatMessage[]): string { const lastUser = userMsgs[userMsgs.length - 1]?.content ?? ''; if (system.length === 0) return lastUser; const sysContent = system.map(m => m.content).join('\n'); - return `[System Instruction]\n${sysContent}\n\n[Current Query]\n${lastUser}`; -} \ No newline at end of file + return `[系统指令]\n${sysContent}\n\n[当前问题]\n${lastUser}`; +} diff --git a/src/mimo/session-marker.ts b/src/mimo/session-marker.ts old mode 100644 new mode 100755 index 307b42f..74ec915 --- a/src/mimo/session-marker.ts +++ b/src/mimo/session-marker.ts @@ -1,4 +1,5 @@ import { createHash } from 'crypto'; +import { randomUUID } from 'crypto'; import { Context } from 'hono'; /** @@ -60,14 +61,50 @@ export function calculateMessageFingerprint(messages: any[]): string { /** * 生成客户端会话标识(备用方案) */ -export function generateClientSessionId(c: Context, accountId: string): string { - // 优先使用客户端提供的会话ID +export type SessionIsolationMode = 'manual' | 'auto' | 'per-request'; + +function hashSessionHint(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 24); +} + +/** + * 生成客户端会话标识(备用方案) + */ +export function generateClientSessionId( + c: Context, + accountId: string, + requestSessionKey?: string | null, + isolationMode: SessionIsolationMode = 'manual' +): string { + // Responses API 的 conversation/previous_response_id 优先级最高。 + if (requestSessionKey) { + console.log('[SESSION] Using explicit session ID from request body'); + return `explicit_${accountId}_${requestSessionKey}`; + } + + // 其次使用客户端提供的会话ID const explicitSessionId = c.req.header('x-session-id'); if (explicitSessionId) { console.log('[SESSION] Using explicit session ID from header'); return `explicit_${accountId}_${explicitSessionId}`; } + if (isolationMode === 'per-request') { + console.log('[SESSION] Using per-request session'); + return `request_${accountId}_${randomUUID()}`; + } + + if (isolationMode === 'auto') { + const forwardedFor = c.req.header('x-forwarded-for')?.split(',')[0]?.trim(); + const ip = forwardedFor + || c.req.header('x-real-ip') + || c.req.header('cf-connecting-ip') + || 'unknown-ip'; + const userAgent = c.req.header('user-agent') || 'unknown-ua'; + console.log('[SESSION] Using auto session isolation'); + return `auto_${accountId}_${hashSessionHint(`${ip}|${userAgent}`)}`; + } + // 默认:基于账号的会话 console.log('[SESSION] Using account-based session (fallback)'); return `account_${accountId}`; diff --git a/src/mimo/session.ts b/src/mimo/session.ts old mode 100644 new mode 100755 index 490ac5b..b6e6bed --- a/src/mimo/session.ts +++ b/src/mimo/session.ts @@ -1,4 +1,4 @@ -import { loadConfig, saveConfig, SessionRecord } from '../db.js'; +import { db } from '../db.js'; import { randomUUID } from 'crypto'; import { config } from '../config.js'; import { calculateMessageFingerprint } from './session-marker.js'; @@ -16,6 +16,19 @@ export interface Session { last_used_at: string; } +/** + * 获取或创建会话(基于消息历史连续性) + * + * 逻辑: + * 1. 计算当前消息的指纹 + * 2. 查找是否有 session 的 last_message_fingerprint 是当前消息的前缀 + * 3. 如果找到,说明当前消息包含了上次的历史 → 复用会话 + * 4. 如果没找到 → 创建新会话 + * + * @param accountId - 账号ID + * @param clientSessionId - 客户端会话ID(用于创建新会话) + * @param messages - 当前请求的消息列表 + */ export async function getOrCreateSession( accountId: string, clientSessionId: string, @@ -30,34 +43,69 @@ export async function getOrCreateSession( fingerprint: currentFingerprint.slice(0, 16) + '...' }); - const configData = loadConfig(); - if (!configData.sessions) configData.sessions = []; + // 显式会话(x-session-id / Responses conversation)应支持客户端只发送增量消息。 + if (shouldReuseByClientSessionId(clientSessionId)) { + const pinnedSession = db.prepare( + 'SELECT * FROM sessions WHERE account_id = ? AND client_session_id = ? AND is_expired = 0' + ).get(accountId, clientSessionId) as Session | undefined; - const activeSessions = configData.sessions - .filter(s => s.account_id === accountId && s.is_expired === 0) - .sort((a, b) => b.last_used_at.localeCompare(a.last_used_at)) - .slice(0, 10); + if (pinnedSession) { + if (isHistoryContaminated(messages)) { + console.log('[SESSION] ⚠️ History contamination detected in pinned session, creating new session...'); + } else if (pinnedSession.cumulative_prompt_tokens > config.contextResetThreshold && config.contextResetThreshold > 0) { + console.log('[SESSION] Token limit exceeded for pinned session, creating new session...'); + } else { + db.prepare( + `UPDATE sessions SET + last_message_fingerprint = ?, + last_used_at = datetime('now') + WHERE id = ?` + ).run(currentFingerprint, pinnedSession.id); + + const updatedSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(pinnedSession.id) as Session; + console.log('[SESSION] ✓ Reusing pinned client session:', { + id: updatedSession.id.slice(0, 8) + '...', + conversationId: updatedSession.conversation_id.slice(0, 16) + '...', + clientSessionId: clientSessionId.slice(0, 20) + '...' + }); + return { conversationId: updatedSession.conversation_id, session: updatedSession }; + } + } + } - console.log(`[SESSION] Found ${activeSessions.length} active sessions for this account`); + // 查找所有活跃的会话,检查消息连续性 + const activeSessions = db.prepare( + 'SELECT * FROM sessions WHERE account_id = ? AND is_expired = 0 ORDER BY last_used_at DESC LIMIT 10' + ).all(accountId) as Session[]; + console.log(`[SESSION] Found ${activeSessions.length} active sessions for this account`); + for (const session of activeSessions) { console.log(`[SESSION] Checking session ${session.id.slice(0, 8)}..., fingerprint: ${session.last_message_fingerprint.slice(0, 16)}...`); + // 检查当前消息是否包含上次的消息(通过比较指纹) + // 如果当前消息更长,且包含了之前的内容,说明是连续的 if (isMessageContinuation(messages, session.last_message_fingerprint)) { + // 检测历史是否被污染(只在复用会话时检查) if (isHistoryContaminated(messages)) { console.log('[SESSION] ⚠️ History contamination detected in continuation, forcing new session...'); - break; + break; // 跳出循环,创建新会话 } + // Token 超限检查 if (session.cumulative_prompt_tokens > config.contextResetThreshold && config.contextResetThreshold > 0) { console.log('[SESSION] Token limit exceeded, creating new session...'); - break; + break; // 跳出循环,创建新会话 } - session.last_message_fingerprint = currentFingerprint; - session.last_used_at = new Date().toISOString(); - saveConfig(configData); - + // 复用现有会话,更新指纹 + db.prepare( + `UPDATE sessions SET + last_message_fingerprint = ?, + last_used_at = datetime('now') + WHERE id = ?` + ).run(currentFingerprint, session.id); + console.log('[SESSION] ✓ Reusing session (message continuation detected):', { id: session.id.slice(0, 8) + '...', conversationId: session.conversation_id.slice(0, 16) + '...', @@ -65,11 +113,14 @@ export async function getOrCreateSession( previousMsgCount: extractMessageCount(session.last_message_fingerprint), currentMsgCount: messages.length }); - - return { conversationId: session.conversation_id, session }; + + // 重新获取更新后的 session + const updatedSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(session.id) as Session; + return { conversationId: updatedSession.conversation_id, session: updatedSession }; } } + // 没有找到连续的会话 → 创建新会话 console.log('[SESSION] No continuation found, creating new session...'); try { return createNewSession(accountId, clientSessionId, currentFingerprint); @@ -79,14 +130,29 @@ export async function getOrCreateSession( } } +function shouldReuseByClientSessionId(clientSessionId: string): boolean { + return clientSessionId.startsWith('explicit_') || clientSessionId.startsWith('auto_'); +} + +/** + * 检查消息是否是连续的 + * 策略:检查当前消息的前 N 条是否与上次的指纹匹配 + */ function isMessageContinuation(currentMessages: any[], lastFingerprint: string): boolean { + // 如果是首次请求(没有历史指纹),不是连续 if (!lastFingerprint) return false; + // 过滤掉 system 消息 const nonSystemMessages = currentMessages.filter(m => m.role !== 'system'); + + // 如果只有1条非 system 消息,无法判断连续性(可能是新对话) if (nonSystemMessages.length < 2) return false; + // 尝试不同的切片长度,看是否能匹配上次的指纹 + // 从最长的开始往前查找(优先匹配完整历史) for (let i = nonSystemMessages.length; i >= 1; i--) { const slice = nonSystemMessages.slice(0, i); + // 需要加回 system 消息来计算指纹(因为 calculateMessageFingerprint 会过滤) const sliceWithSystem = currentMessages.filter(m => m.role === 'system').concat(slice); const sliceFingerprint = calculateMessageFingerprint(sliceWithSystem); @@ -101,99 +167,94 @@ function isMessageContinuation(currentMessages: any[], lastFingerprint: string): return false; } +/** + * 从指纹中提取消息数量(用于日志) + */ function extractMessageCount(fingerprint: string): string { - return 'N/A'; + return 'N/A'; // 指纹中不包含消息数量,仅用于日志显示 } +/** + * 创建新会话 + */ function createNewSession(accountId: string, clientSessionId: string, messageFingerprint: string): { conversationId: string; session: Session } { console.log('[SESSION] createNewSession called:', { accountId: accountId.slice(0, 8) + '...', clientSessionId: clientSessionId.slice(0, 20) + '...', fingerprint: messageFingerprint.slice(0, 16) + '...' }); - + try { - const configData = loadConfig(); - if (!configData.sessions) configData.sessions = []; - - const id = randomUUID(); - const conversationId = randomUUID().replace(/-/g, ''); - - console.log('[SESSION] Deleting old sessions with same client_session_id...'); - const beforeCount = configData.sessions.length; - configData.sessions = configData.sessions.filter( - s => !(s.account_id === accountId && s.client_session_id === clientSessionId && s.is_expired === 0) - ); - console.log('[SESSION] Deleted', beforeCount - configData.sessions.length, 'old sessions'); - - console.log('[SESSION] Inserting new session...'); - const newSession: SessionRecord = { - id, - account_id: accountId, - client_session_id: clientSessionId, - conversation_id: conversationId, - last_message_fingerprint: messageFingerprint, - cumulative_prompt_tokens: 0, - is_expired: 0, - created_at: new Date().toISOString(), - last_used_at: new Date().toISOString(), - }; - - configData.sessions.push(newSession); - saveConfig(configData); - + const transaction = db.transaction(() => { + const id = randomUUID(); + const conversationId = randomUUID().replace(/-/g, ''); + + console.log('[SESSION] Deleting old sessions with same client_session_id...'); + // 先删除旧的同名 session(如果存在) + const deleteResult = db.prepare( + `DELETE FROM sessions + WHERE account_id = ? AND client_session_id = ? AND is_expired = 0` + ).run(accountId, clientSessionId); + console.log('[SESSION] Deleted', deleteResult.changes, 'old sessions'); + + console.log('[SESSION] Inserting new session...'); + // 创建新 session + db.prepare( + `INSERT INTO sessions + (id, account_id, client_session_id, conversation_id, last_message_fingerprint, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))` + ).run(id, accountId, clientSessionId, conversationId, messageFingerprint); + + return { id, conversationId }; + }); + + const result = transaction(); + const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.id) as Session; + console.log('[SESSION] ✓ New session created:', { - id: id.slice(0, 8) + '...', - conversationId: conversationId.slice(0, 16) + '...', + id: result.id.slice(0, 8) + '...', + conversationId: result.conversationId.slice(0, 16) + '...', fingerprint: messageFingerprint.slice(0, 16) + '...' }); - - return { conversationId, session: newSession }; + + return { conversationId: result.conversationId, session }; } catch (error) { console.error('[SESSION] ❌ Error in createNewSession:', error); throw error; } } +/** + * 更新会话 token 统计 + */ export function updateSessionTokens(sessionId: string, promptTokens: number) { console.log('[SESSION] updateSessionTokens:', { sessionId: sessionId.slice(0, 8) + '...', promptTokens }); - - const configData = loadConfig(); - if (!configData.sessions) return; - - const session = configData.sessions.find(s => s.id === sessionId); - if (session) { - session.cumulative_prompt_tokens += promptTokens; - session.last_used_at = new Date().toISOString(); - saveConfig(configData); - } + + const transaction = db.transaction(() => { + db.prepare( + `UPDATE sessions SET + cumulative_prompt_tokens = cumulative_prompt_tokens + ?, + last_used_at = datetime('now') + WHERE id = ?` + ).run(promptTokens, sessionId); + }); + + transaction(); } export function expireSession(sessionId: string) { - const configData = loadConfig(); - if (!configData.sessions) return; - - const session = configData.sessions.find(s => s.id === sessionId); - if (session) { - session.is_expired = 1; - saveConfig(configData); - } + db.prepare('UPDATE sessions SET is_expired = 1 WHERE id = ?').run(sessionId); } export function listSessions(): Session[] { - const configData = loadConfig(); - return (configData.sessions || []) - .filter(s => s.is_expired === 0) - .sort((a, b) => b.last_used_at.localeCompare(a.last_used_at)); + return db.prepare( + 'SELECT * FROM sessions WHERE is_expired = 0 ORDER BY last_used_at DESC' + ).all() as Session[]; } export function deleteSession(id: string) { - const configData = loadConfig(); - if (!configData.sessions) return; - - configData.sessions = configData.sessions.filter(s => s.id !== id); - saveConfig(configData); + db.prepare('DELETE FROM sessions WHERE id = ?').run(id); } diff --git a/src/mimo/upload.ts b/src/mimo/upload.ts old mode 100644 new mode 100755 index 1f07854..4a44e74 --- a/src/mimo/upload.ts +++ b/src/mimo/upload.ts @@ -1,5 +1,6 @@ import { createHash } from 'crypto'; import { Account } from '../accounts.js'; +import { getMimoProxyFetchOptions } from './proxy-agent.js'; const BASE_URL = 'https://aistudio.xiaomimimo.com'; @@ -66,7 +67,8 @@ export async function uploadImageToMimo(account: Account, imageData: Buffer, mim method: 'POST', headers: makeHeaders(account), body: JSON.stringify({ fileName, fileContentMd5: md5 }), - } + ...getMimoProxyFetchOptions(), + } as RequestInit ); if (!infoResp.ok) throw new Error(`genUploadInfo failed: ${infoResp.status}`); const infoJson = await infoResp.json() as { code: number; data: { resourceUrl: string; uploadUrl: string } }; @@ -87,7 +89,7 @@ export async function uploadImageToMimo(account: Account, imageData: Buffer, mim // Step 3: parse (register resource) const parseResp = await fetch( `${BASE_URL}/open-apis/resource/parse?fileUrl=${encodeURIComponent(resourceUrl)}&xiaomichatbot_ph=${ph}`, - { method: 'POST', headers: makeHeaders(account) } + { method: 'POST', headers: makeHeaders(account), ...getMimoProxyFetchOptions() } as RequestInit ); if (!parseResp.ok) throw new Error(`parse failed: ${parseResp.status}`); @@ -105,4 +107,4 @@ export async function uploadImageToMimo(account: Account, imageData: Buffer, mim objectName, url: md5, }; -} \ No newline at end of file +} diff --git a/src/tools/format.ts b/src/tools/format.ts old mode 100644 new mode 100755 index 2880b61..7162804 --- a/src/tools/format.ts +++ b/src/tools/format.ts @@ -43,6 +43,6 @@ export function formatToolResultMessages(messages: Array<{ role: string; content if (!toolMsgs.length) return ''; return toolMsgs.map(m => { const c = m as { tool_call_id?: string; name?: string; content: unknown }; - return `[Tool Result] ${c.name ?? ''} (${c.tool_call_id ?? ''}):\n${typeof c.content === 'string' ? c.content : JSON.stringify(c.content)}`; + return `[工具结果] ${c.name ?? ''} (${c.tool_call_id ?? ''}):\n${typeof c.content === 'string' ? c.content : JSON.stringify(c.content)}`; }).join('\n\n'); } diff --git a/src/tools/parser.ts b/src/tools/parser.ts old mode 100644 new mode 100755 index 2d9357a..c88fb8e --- a/src/tools/parser.ts +++ b/src/tools/parser.ts @@ -49,23 +49,30 @@ function cleanInvisibleChars(text: string): string { } // 改进的 JSON 修复 +function repairInvalidJsonEscapes(json: string): string { + return json.replace(/\\(?!["\\/bfnrtu])/g, () => '\\\\'); +} + function repairJson(json: string): string { let repaired = json; - // 1. 移除尾随逗号 + // 1. 修复 JSON 不支持的转义,例如 shell 字符串里的 \' 或 Windows 路径里的 \U + repaired = repairInvalidJsonEscapes(repaired); + + // 2. 移除尾随逗号 repaired = repaired.replace(/,(\s*[}\]])/g, '$1'); - // 2. 移除开头逗号 + // 3. 移除开头逗号 repaired = repaired.replace(/([{\[])\s*,/g, '$1'); - // 3. 移除注释(简单处理) + // 4. 移除注释(简单处理) repaired = repaired.replace(/\/\*[\s\S]*?\*\//g, ''); repaired = repaired.replace(/\/\/.*/g, ''); - // 4. 修复数字后多余的引号:123"}} -> 123}} + // 5. 修复数字后多余的引号:123"}} -> 123}} repaired = repaired.replace(/(\d+)"([}\],])/g, '$1$2'); - // 5. 修复字符串值后缺少引号:": value, -> ": "value", + // 6. 修复字符串值后缺少引号:": value, -> ": "value", repaired = repaired.replace(/:\s*([^"{[\d\s][^,}\]]*?)([,}\]])/g, (match, value, end) => { const trimmed = value.trim(); if (trimmed === 'true' || trimmed === 'false' || trimmed === 'null') { @@ -84,36 +91,41 @@ function parseJsonSafely(text: string): any { return JSON.parse(text); } catch (firstError) { try { - // 尝试修复后解析 - return JSON.parse(repairJson(text)); + // 先做最小修复,避免后续宽松正则误伤命令字符串里的冒号、路径等内容 + return JSON.parse(repairInvalidJsonEscapes(text)); } catch (secondError) { - // 如果还是失败,尝试更激进的修复: - // 找到所有字符串值,并确保它们被正确转义 - let fixed = text; - - // 匹配 "key": "value" 模式,其中 value 可能包含未转义的换行符 - // 使用负向后查找确保引号前没有反斜杠 - fixed = fixed.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (match, content) => { - // 如果内容已经被正确转义,直接返回 - if (!content.includes('\n') && !content.includes('\r') && !content.includes('\t')) { - return match; - } - - // 否则,重新转义 - const escaped = content - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t'); - - return `"${escaped}"`; - }); - try { - return JSON.parse(fixed); + // 尝试完整修复后解析 + return JSON.parse(repairJson(text)); } catch (thirdError) { - // 最后尝试:移除所有实际的换行符,只保留转义的 - const noNewlines = text.replace(/([^\\])\n/g, '$1\\n').replace(/([^\\])\r/g, '$1\\r'); - return JSON.parse(noNewlines); + // 如果还是失败,尝试更激进的修复: + // 找到所有字符串值,并确保它们被正确转义 + let fixed = repairInvalidJsonEscapes(text); + + // 匹配 "key": "value" 模式,其中 value 可能包含未转义的换行符 + // 使用负向后查找确保引号前没有反斜杠 + fixed = fixed.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (match, content) => { + // 如果内容已经被正确转义,直接返回 + if (!content.includes('\n') && !content.includes('\r') && !content.includes('\t')) { + return match; + } + + // 否则,重新转义 + const escaped = content + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); + + return `"${escaped}"`; + }); + + try { + return JSON.parse(fixed); + } catch (fourthError) { + // 最后尝试:移除所有实际的换行符,只保留转义的 + const noNewlines = repairInvalidJsonEscapes(text).replace(/([^\\])\n/g, '$1\\n').replace(/([^\\])\r/g, '$1\\r'); + return JSON.parse(noNewlines); + } } } } @@ -150,6 +162,91 @@ function parseValue(val: string): unknown { } } +function decodeLooseJsonStringContent(value: string): string { + let decoded = ''; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (ch !== '\\' || i === value.length - 1) { + decoded += ch; + continue; + } + + const next = value[++i]; + switch (next) { + case '"': + case '\\': + case '/': + decoded += next; + break; + case 'b': + decoded += '\b'; + break; + case 'f': + decoded += '\f'; + break; + case 'n': + decoded += '\n'; + break; + case 'r': + decoded += '\r'; + break; + case 't': + decoded += '\t'; + break; + case 'u': { + const hex = value.slice(i + 1, i + 5); + if (/^[0-9a-fA-F]{4}$/.test(hex)) { + decoded += String.fromCharCode(parseInt(hex, 16)); + i += 4; + } else { + decoded += '\\u'; + } + break; + } + default: + decoded += `\\${next}`; + break; + } + } + return decoded; +} + +function extractLooseJsonStringField(text: string, field: string): string | undefined { + const markerRe = new RegExp(`"${field}"\\s*:\\s*"`, 'i'); + const marker = markerRe.exec(text); + if (!marker || marker.index === undefined) return undefined; + + const start = marker.index + marker[0].length; + for (let i = start; i < text.length; i++) { + if (text[i] !== '"') continue; + + const backslashes = text.slice(start, i).match(/\\+$/)?.[0].length ?? 0; + if (backslashes % 2 === 1) continue; + + const rest = text.slice(i + 1); + if (/^\s*(?:[,}\]])/.test(rest)) { + return decodeLooseJsonStringContent(text.slice(start, i)); + } + } + + return undefined; +} + +function parseLooseNamedJsonToolCall(text: string, callId: string): ParsedToolCall | null { + const name = extractLooseJsonStringField(text, 'name'); + if (!name) return null; + + const args: Record = {}; + const stringFields = ['command', 'file_path', 'path', 'content', 'old_string', 'new_string', 'pattern', 'url', 'query']; + for (const field of stringFields) { + const value = extractLooseJsonStringField(text, field); + if (value !== undefined) args[field] = value; + } + + if (Object.keys(args).length === 0) return null; + return { id: callId, name, arguments: args }; +} + // 改进的 XML 参数解析 function parseXmlParam(xml: string): Record { const trimmed = xml.trim(); @@ -393,6 +490,13 @@ function parseMimoNativeToolCalls(text: string): ParsedToolCall[] { continue; } } catch (err) { + const looseCall = parseLooseNamedJsonToolCall(inner, callId); + if (looseCall) { + log('info', `Parsed loose named JSON tool call: ${looseCall.name}`, { args: looseCall.arguments }); + calls.push(looseCall); + continue; + } + // 尝试解析多个 JSON 对象(当多个工具调用在一个 块内时) const multiCalls = parseNamedJsonToolCalls(inner); if (multiCalls.length > 0) { @@ -642,7 +746,7 @@ function parseDirectToolNameFormat(text: string): ParsedToolCall[] { let match: RegExpExecArray | null; let count = 0; - const excludedTags = ['div', 'span', 'p', 'a', 'img', 'br', 'hr', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'blockquote', 'strong', 'em', 'b', 'i', 'u', 'todo', 'todo_list', 'thinking', 'result', 'task_progress', 'path', 'name', 'content', 'question', 'options']; + const excludedTags = ['div', 'span', 'p', 'a', 'img', 'br', 'hr', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'blockquote', 'strong', 'em', 'b', 'i', 'u', 'todo', 'todo_list', 'think', 'thinking', 'result', 'task_progress', 'path', 'name', 'content', 'question', 'options']; while ((match = directToolRe.exec(cleanText)) !== null) { if (++count > CONFIG.MAX_TOOL_CALLS) { @@ -931,7 +1035,7 @@ export function hasToolCallMarker(text: string): boolean { if (match) { const tagName = match[1].toLowerCase(); // 排除常见的 HTML/Markdown 标签 - const excludedTags = ['div', 'span', 'p', 'a', 'img', 'br', 'hr', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'blockquote', 'strong', 'em', 'b', 'i', 'u', 'thinking', 'result', 'task_progress', 'path', 'name', 'content', 'question', 'options']; + const excludedTags = ['div', 'span', 'p', 'a', 'img', 'br', 'hr', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'blockquote', 'strong', 'em', 'b', 'i', 'u', 'think', 'thinking', 'result', 'task_progress', 'path', 'name', 'content', 'question', 'options']; if (!excludedTags.includes(tagName)) { return true; } diff --git a/src/tools/prompt.ts b/src/tools/prompt.ts old mode 100644 new mode 100755 index cd183b7..dc40185 --- a/src/tools/prompt.ts +++ b/src/tools/prompt.ts @@ -1,14 +1,15 @@ export interface ToolDefinition { - // OpenAI format + // OpenAI Chat Completions format type?: 'function'; function?: { name: string; description?: string; parameters?: Record; }; - // Anthropic format + // Anthropic format / OpenAI Responses API format (name, description, parameters at top level) name?: string; description?: string; + parameters?: Record; input_schema?: Record; } @@ -20,7 +21,7 @@ interface NormalizedTool { function normalizeTool(t: ToolDefinition): NormalizedTool { if (t.function) return { name: t.function.name, description: t.function.description, parameters: t.function.parameters }; - return { name: t.name!, description: t.description, parameters: t.input_schema }; + return { name: t.name!, description: t.description, parameters: t.parameters ?? t.input_schema }; } // 递归生成参数 schema 描述,保留嵌套结构 @@ -94,17 +95,17 @@ export function buildToolSystemPrompt(tools: ToolDefinition[]): string { return `## ${fn.name}${desc}${paramBlock}`; }).join('\n\n'); - return `[Tool Call Format - Strictly Enforced] + return `[工具调用格式 - 必须严格遵守] -{"name": "tool_name", "arguments": {"param": "value"}} +{"name": "工具名", "arguments": {"参数": "值"}} -Requirements: -• Must wrap JSON with tags -• JSON must include "name" and "arguments" fields -• Do not output bash commands or markdown code blocks -• Do not output system tags such as , -• Use English only for all tags and labels +要求: +• 必须用 标签包裹 JSON +• JSON 必须有 "name" 和 "arguments" 字段 +• 禁止输出 bash 命令或 markdown 代码块 +• 禁止输出 等系统标签 +• 禁止使用中文标签(如 <函数调用>、<函数名> 等) -Available Tools: ${toolDescs}`; +可用工具:${toolDescs}`; } \ No newline at end of file diff --git a/src/web/chart.js b/src/web/chart.js old mode 100644 new mode 100755 diff --git a/src/web/index.html b/src/web/index.html old mode 100644 new mode 100755 index b180348..2632a3a --- a/src/web/index.html +++ b/src/web/index.html @@ -131,6 +131,19 @@ /* Toast */ .toast-item{background:var(--c-toast-bg);border:1px solid var(--c-border);border-radius:var(--radius-sm);padding:12px 16px;margin-top:8px;font-size:13px;animation:slideIn .3s ease;display:flex;align-items:center;gap:8px;box-shadow:0 4px 12px rgba(0,0,0,.2)} + /* Detail Modal */ + .detail-section{margin-bottom:16px} + .detail-section-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-4);margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--c-border)} + .detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 16px} + .detail-item{display:flex;flex-direction:column;gap:2px} + .detail-label{font-size:11px;color:var(--c-text-5)} + .detail-value{font-size:13px;color:var(--c-text-2);font-family:var(--font-mono),monospace;word-break:break-all} + .detail-body-box{background:var(--c-surface-2);border:1px solid var(--c-border);border-radius:var(--radius-sm);padding:10px 12px;font-size:12px;font-family:var(--font-mono),monospace;color:var(--c-text-3);max-height:240px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;line-height:1.5} + .detail-body-toggle{cursor:pointer;font-size:12px;color:var(--c-accent);display:inline-flex;align-items:center;gap:4px;margin-bottom:6px;user-select:none} + .detail-body-toggle:hover{text-decoration:underline} + .detail-log-row{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--c-border);font-size:12px} + .detail-log-row:last-child{border-bottom:none} + /* Modal */ .modal-card{animation:modalIn .2s ease;border-radius:var(--radius-lg);background:var(--c-surface);border:1px solid var(--c-border);box-shadow:0 25px 50px -12px rgba(0,0,0,.4)} @@ -198,6 +211,19 @@

+ +
- - + +
-
时间端点模型输入 Token输出 Token耗时状态
+
时间端点模型输入 Token输出 Token耗时状态操作
@@ -386,6 +412,14 @@

+
+ +
+ + +
+ 只用于 aistudio.xiaomimimo.com 的 MiMo 请求;留空则直连,不影响其他出站请求 +
@@ -421,6 +455,30 @@

':'';el.innerHTML=''+icon+''+msg+'';document.getElementById('toast').appendChild(el);setTimeout(function(){el.remove()},3000)}; var monitorTimer=null; function showPage(name,el){document.querySelectorAll('.page').forEach(function(p){p.classList.remove('active')});document.querySelectorAll('.nav-item').forEach(function(n){n.classList.remove('active')});el.classList.add('active');document.getElementById('page-'+name).classList.add('active');clearInterval(monitorTimer);if(name==='monitor'){loadMonitor();monitorTimer=setInterval(loadMonitor,3000)}else{({accounts:loadAccounts,apikeys:loadApiKeys,stats:loadStats,logs:function(){loadLogs(1)},sessions:loadSessions,settings:loadSettings})[name]&&({accounts:loadAccounts,apikeys:loadApiKeys,stats:loadStats,logs:function(){loadLogs(1)},sessions:loadSessions,settings:loadSettings})[name]()}} @@ -428,113 +486,322 @@

共 '+total+' 条';if(start>1)html+='';if(start>2)html+='...';for(var i=start;i<=end;i++){html+=''}if(end...';if(end'+totalPages+'';pg.innerHTML=html} var accountPage=1; -async function loadAccounts(page){if(page)accountPage=page;var d=await api('/admin/accounts?page='+accountPage+'&limit=10');var data=d.accounts||[];var tbody=document.getElementById('accountTable');if(data.length===0){tbody.innerHTML='

暂无账号数据

点击上方"添加账号"开始

';document.getElementById('accountPagination').innerHTML='';return}tbody.innerHTML=data.map(function(a){var totalTokens=(a.total_prompt_tokens||0)+(a.total_completion_tokens||0);var createdDate=a.created_at?new Date(a.created_at).toLocaleDateString('zh-CN',{month:'2-digit',day:'2-digit'}):'-';return ''+((a.alias||'-'))+''+(a.user_id||'-')+''+(a.is_active?'启用':'禁用')+''+a.active_requests+''+((a.total_requests||0).toLocaleString())+''+(totalTokens>0?(totalTokens/1000).toFixed(1)+'K':'-')+''+createdDate+'
'}).join('');renderPagination('accountPagination',d.total,d.limit,accountPage,'loadAccounts')} +function renderAccounts(d){ + var data=d.accounts||[]; + var tbody=document.getElementById('accountTable'); + if(!tbody) return; + if(data.length===0){ + tbody.innerHTML='

暂无账号数据

点击上方"添加账号"开始

'; + document.getElementById('accountPagination').innerHTML=''; + return; + } + tbody.innerHTML=data.map(function(a){ + var totalTokens=(a.total_prompt_tokens||0)+(a.total_completion_tokens||0); + var createdDate=a.created_at?new Date(a.created_at).toLocaleDateString('zh-CN',{month:'2-digit',day:'2-digit'}):'-'; + return ''+((a.alias||'-'))+''+(a.user_id||'-')+''+(a.is_active?'启用':'禁用')+''+a.active_requests+''+((a.total_requests||0).toLocaleString())+''+(totalTokens>0?(totalTokens/1000).toFixed(1)+'K':'-')+''+createdDate+'
'; + }).join(''); + renderPagination('accountPagination',d.total,d.limit,accountPage,'loadAccounts'); +} +async function loadAccounts(page){ + if(page)accountPage=page; + var cacheKey = 'accounts_' + accountPage; + var cached = getCache(cacheKey); + if (cached) renderAccounts(cached); + try { + var d=await api('/admin/accounts?page='+accountPage+'&limit=10'); + setCache(cacheKey, d); + renderAccounts(d); + } catch (e) { + console.error('Failed to load accounts:', e); + if (!cached) { + document.getElementById('accountTable').innerHTML='

加载失败,请检查网络

'; + } + } +} + async function addAccount(){var curl=document.getElementById('addCurl').value.trim(),alias=document.getElementById('addAlias').value.trim();if(!curl){toast('请粘贴 cURL 命令',false);return}var res=await api('/admin/accounts',{method:'POST',body:{curl:curl,alias:alias}});res.api_key?toast('添加成功,API Key: '+res.api_key):toast(res.error||'添加失败',false);if(res.api_key)loadAccounts()} function toggleManual(){document.getElementById('manualForm').classList.toggle('hidden')} async function addAccountManual(){var body={service_token:document.getElementById('mST').value.trim(),user_id:document.getElementById('mUID').value.trim(),ph_token:document.getElementById('mPH').value.trim(),alias:document.getElementById('addAlias').value.trim()};if(!body.service_token){toast('serviceToken不能为空',false);return}var res=await api('/admin/accounts',{method:'POST',body:body});res.api_key?toast('添加成功: '+res.api_key):toast(res.error||'失败',false);if(res.api_key)loadAccounts()} -function editAccount(id,alias){showModal({title:'修改别名',message:'请输入新的别名',type:'prompt',defaultValue:alias,placeholder:'别名'}).then(function(newAlias){if(newAlias===null)return;api('/admin/accounts/'+id,{method:'PATCH',body:{alias:newAlias}}).then(function(r){r.id?toast('别名已更新'):toast(r.error||'更新失败',false);loadAccounts()})})} +function editAccount(id,alias){showModal({title:'修改别名',message:'请输入新的别名',type:'prompt',defaultValue:alias,placeholder:'别名'}).then(function(newName){if(newName===null)return;api('/admin/accounts/'+id,{method:'PATCH',body:{alias:newName}}).then(function(r){r.id?toast('别名已更新'):toast(r.error||'更新失败',false);loadAccounts()})})} async function testAccount(id){toast('测试中...');var res=await api('/admin/accounts/test',{method:'POST',body:{id:id}});res.success?toast('有效: '+res.response.slice(0,80)):toast('无效: '+res.error,false)} async function toggleAccount(id,cur){await api('/admin/accounts/'+id,{method:'PATCH',body:{is_active:cur?0:1}});loadAccounts()} async function removeAccount(id){var ok=await showModal({title:'删除账号',message:'确认删除此账号?删除后不可恢复。',dangerous:true,confirmText:'删除'});if(!ok)return;await api('/admin/accounts/'+id,{method:'DELETE'});toast('已删除');loadAccounts()} var monitorPage=1; -async function loadMonitor(page){if(page)monitorPage=page;var s=await api('/admin/stats?page='+monitorPage+'&limit=10');var accs=s.accounts||[];var maxConcurrent=s.maxConcurrent||5;var totalActive=accs.reduce(function(x,a){return x+a.active_requests},0);document.getElementById('monitorStats').innerHTML='
总账号
'+(s.totalAccounts||0)+'
当前并发
'+totalActive+'
当前页请求数
'+accs.reduce(function(x,a){return x+a.total_requests},0)+'
';document.getElementById('monitorTable').innerHTML=accs.map(function(a){var percent=Math.round((a.active_requests/maxConcurrent)*100);var barColor=percent>80?'var(--c-accent-red)':percent>50?'var(--c-accent-amber)':'var(--c-accent-green)';return ''+((a.alias||'-'))+''+((a.user_id||'').slice(0,8)||'-')+''+a.active_requests+'/'+maxConcurrent+'
'+percent+'%
'+(a.is_active?'启用':'禁用')+''}).join('');renderPagination('monitorPagination',s.totalAccounts,s.limit,monitorPage,'loadMonitor')} +function renderMonitor(s){ + var accs=s.accounts||[]; + var maxConcurrent=s.maxConcurrent||5; + var totalActive=accs.reduce(function(x,a){return x+a.active_requests},0); + var monitorStatsEl = document.getElementById('monitorStats'); + if (monitorStatsEl) { + monitorStatsEl.innerHTML='
总账号
'+(s.totalAccounts||0)+'
当前并发
'+totalActive+'
当前页请求数
'+accs.reduce(function(x,a){return x+a.total_requests},0)+'
'; + } + var monitorTableEl = document.getElementById('monitorTable'); + if (monitorTableEl) { + monitorTableEl.innerHTML=accs.map(function(a){var percent=Math.round((a.active_requests/maxConcurrent)*100);var barColor=percent>80?'var(--c-accent-red)':percent>50?'var(--c-accent-amber)':'var(--c-accent-green)';return ''+((a.alias||'-'))+''+((a.user_id||'').slice(0,8)||'-')+''+a.active_requests+'/'+maxConcurrent+'
'+percent+'%
'+(a.is_active?'启用':'禁用')+''}).join(''); + } + renderPagination('monitorPagination',s.totalAccounts,s.limit,monitorPage,'loadMonitor'); +} +async function loadMonitor(page){ + if(page)monitorPage=page; + var cacheKey = 'monitor_' + monitorPage; + var cached = getCache(cacheKey); + if (cached) renderMonitor(cached); + try { + var s=await api('/admin/stats?page='+monitorPage+'&limit=10'); + setCache(cacheKey, s); + renderMonitor(s); + } catch (e) { + console.error(e); + if (!cached) { + var monitorTableEl = document.getElementById('monitorTable'); + if (monitorTableEl) { + monitorTableEl.innerHTML = '

加载失败,请检查网络

'; + } + } + } +} var charts={token:null,trend:null,endpoint:null,model:null,hourly:null}; var CHART_COLORS=['#818cf8','#34d399','#fbbf24','#f87171','#60a5fa','#a78bfa','#fb923c','#2dd4bf','#e879f9','#94a3b8']; -function chartTheme(){var isLight=document.documentElement.dataset.theme==='light';return{text:isLight?'#5c6478':'#5c6478',grid:isLight?'#e2e6ee':'#1a1e2e',surface:isLight?'#ffffff':'#141722'}} +function chartTheme(){var isLight=document.documentElement.dataset.theme==='light';return{text:isLight?'#5c6478':'#5c6478',grid:isLight?'#e2e6ee':'#1a1e2e',surface:isLight?'#ffffff':'#141722',ttBg:isLight?'#ffffff':'#1e2330',ttTitle:isLight?'#1e293b':'#e2e8f0',ttBody:isLight?'#475569':'#94a3b8',ttBorder:isLight?'#e2e6ee':'#2d3348'}} function destroyChart(k){if(charts[k]){charts[k].destroy();charts[k]=null}} function fmtNum(n){if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'K';return String(n)} function diffPct(cur,prev){if(!prev)return cur>0?'↑ --':'--';var pct=Math.round(((cur-prev)/prev)*100);return pct>=0?'↑ '+pct+'%':'↓ '+Math.abs(pct)+'%'} var statsAccountPage=1,statsApiKeyPage=1; -async function loadStats(){ - var ov=await api('/admin/stats/overview'); +function renderStats(ov){ var t=chartTheme(); // KPI var td=ov.today||{};var yd=ov.yesterday||{}; - document.getElementById('statsKpi').innerHTML=[ - '
今日请求数
'+fmtNum(td.requests||0)+'
'+diffPct(td.requests||0,yd.requests||0)+' 昨日
', - '
今日 Token
'+fmtNum(td.tokens||0)+'
'+diffPct(td.tokens||0,yd.tokens||0)+' 昨日
', - '
成功率
'+((td.successRate??100)+'%')+'
平均延迟 '+(td.avgLatency||0)+'ms
', - '
平均响应
'+(td.avgLatency||0)+'ms
per request
' - ].join(''); + var statsKpiEl = document.getElementById('statsKpi'); + if (statsKpiEl) { + statsKpiEl.innerHTML=[ + '
今日请求数
'+fmtNum(td.requests||0)+'
'+diffPct(td.requests||0,yd.requests||0)+' 昨日
', + '
今日 Token
'+fmtNum(td.tokens||0)+'
'+diffPct(td.tokens||0,yd.tokens||0)+' 昨日
', + '
成功率
'+((td.successRate??100)+'%')+'
平均延迟 '+(td.avgLatency||0)+'ms
', + '
平均响应
'+(td.avgLatency||0)+'ms
per request
' + ].join(''); + } // 趋势 var trend=ov.dailyTrend||[]; destroyChart('trend'); if(trend.length){ - var ctx=document.getElementById('trendChart').getContext('2d'); - var gradIndigo=ctx.createLinearGradient(0,0,0,240);gradIndigo.addColorStop(0,'rgba(129,140,248,.2)');gradIndigo.addColorStop(1,'rgba(129,140,248,0)'); - var gradGreen=ctx.createLinearGradient(0,0,0,240);gradGreen.addColorStop(0,'rgba(52,211,153,.15)');gradGreen.addColorStop(1,'rgba(52,211,153,0)'); - charts.trend=new Chart(ctx,{type:'line',data:{labels:trend.map(function(d){return d.date.slice(5)}),datasets:[{label:'Input Token',data:trend.map(function(d){return d.input_tokens}),borderColor:'#818cf8',backgroundColor:gradIndigo,fill:true,tension:.35,pointRadius:0,pointHoverRadius:4,borderWidth:2},{label:'Output Token',data:trend.map(function(d){return d.output_tokens}),borderColor:'#34d399',backgroundColor:gradGreen,fill:true,tension:.35,pointRadius:0,pointHoverRadius:4,borderWidth:2}]},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{labels:{color:t.text,boxWidth:12,boxHeight:2,padding:16,usePointStyle:true}},tooltip:{backgroundColor:'var(--c-surface)',titleColor:'var(--c-text)',bodyColor:'var(--c-text-3)',borderColor:'var(--c-border)',borderWidth:1,padding:10,cornerRadius:8,callbacks:{label:function(ctx){return ctx.dataset.label+': '+fmtNum(ctx.raw)}}}},scales:{x:{ticks:{color:t.text,maxRotation:0,autoSkip:true,maxTicksLimit:8,font:{size:11}},grid:{display:false},border:{display:false}},y:{ticks:{color:t.text,callback:function(v){return fmtNum(v)},font:{size:11}},grid:{color:t.grid},border:{display:false}}}}}); + var trendChartEl = document.getElementById('trendChart'); + if (trendChartEl) { + var ctx=trendChartEl.getContext('2d'); + var gradIndigo=ctx.createLinearGradient(0,0,0,240);gradIndigo.addColorStop(0,'rgba(129,140,248,.2)');gradIndigo.addColorStop(1,'rgba(129,140,248,0)'); + var gradGreen=ctx.createLinearGradient(0,0,0,240);gradGreen.addColorStop(0,'rgba(52,211,153,.15)');gradGreen.addColorStop(1,'rgba(52,211,153,0)'); + charts.trend=new Chart(ctx,{type:'line',data:{labels:trend.map(function(d){return d.date.slice(5)}),datasets:[{label:'Input Token',data:trend.map(function(d){return d.input_tokens}),borderColor:'#818cf8',backgroundColor:gradIndigo,fill:true,tension:.35,pointRadius:0,pointHoverRadius:4,borderWidth:2},{label:'Output Token',data:trend.map(function(d){return d.output_tokens}),borderColor:'#34d399',backgroundColor:gradGreen,fill:true,tension:.35,pointRadius:0,pointHoverRadius:4,borderWidth:2}]},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{labels:{color:t.text,boxWidth:12,boxHeight:2,padding:16,usePointStyle:true}},tooltip:{backgroundColor:t.ttBg,titleColor:t.ttTitle,bodyColor:t.ttBody,borderColor:t.ttBorder,borderWidth:1,padding:10,cornerRadius:8,callbacks:{label:function(ctx){return ctx.dataset.label+': '+fmtNum(ctx.raw)}}}},scales:{x:{ticks:{color:t.text,maxRotation:0,autoSkip:true,maxTicksLimit:8,font:{size:11}},grid:{display:false},border:{display:false}},y:{ticks:{color:t.text,callback:function(v){return fmtNum(v)},font:{size:11}},grid:{color:t.grid},border:{display:false}}}}}); + } } // 端点分布 var ep=ov.endpointDist||[]; destroyChart('endpoint'); if(ep.length){ - var epLabels=ep.map(function(e){return e.endpoint||'unknown'}); - var epData=ep.map(function(e){return e.requests}); - var epTotal=epData.reduce(function(a,b){return a+b},0); - charts.endpoint=new Chart(document.getElementById('endpointChart').getContext('2d'),{type:'doughnut',data:{labels:epLabels,datasets:[{data:epData,backgroundColor:CHART_COLORS.slice(0,ep.length),borderWidth:0,hoverOffset:6}]},options:{responsive:false,cutout:'65%',plugins:{legend:{position:'bottom',labels:{color:t.text,padding:10,font:{size:11},usePointStyle:true,pointStyle:'circle'}},tooltip:{callbacks:{label:function(ctx){return ctx.label+': '+ctx.raw+' ('+Math.round(ctx.raw/epTotal*100)+'%)'}}}}}}); + var epChartEl = document.getElementById('endpointChart'); + if (epChartEl) { + var epLabels=ep.map(function(e){return e.endpoint||'unknown'}); + var epData=ep.map(function(e){return e.requests}); + var epTotal=epData.reduce(function(a,b){return a+b},0); + charts.endpoint=new Chart(epChartEl.getContext('2d'),{type:'doughnut',data:{labels:epLabels,datasets:[{data:epData,backgroundColor:CHART_COLORS.slice(0,ep.length),borderWidth:0,hoverOffset:6}]},options:{responsive:false,cutout:'65%',plugins:{legend:{position:'bottom',labels:{color:t.text,padding:10,font:{size:11},usePointStyle:true,pointStyle:'circle'}},tooltip:{backgroundColor:t.ttBg,titleColor:t.ttTitle,bodyColor:t.ttBody,borderColor:t.ttBorder,borderWidth:1,padding:10,cornerRadius:8,callbacks:{label:function(ctx){return ctx.label+': '+ctx.raw+' ('+Math.round(ctx.raw/epTotal*100)+'%)'}}}}}}); + } } // 模型分布 var md=ov.modelDist||[]; destroyChart('model'); if(md.length){ - var mdLabels=md.map(function(m){return m.model||'unknown'}); - var mdData=md.map(function(m){return m.tokens}); - charts.model=new Chart(document.getElementById('modelChart').getContext('2d'),{type:'doughnut',data:{labels:mdLabels,datasets:[{data:mdData,backgroundColor:CHART_COLORS.slice(0,md.length),borderWidth:0,hoverOffset:6}]},options:{responsive:false,cutout:'65%',plugins:{legend:{position:'bottom',labels:{color:t.text,padding:10,font:{size:11},usePointStyle:true,pointStyle:'circle'}},tooltip:{callbacks:{label:function(ctx){return ctx.label+': '+fmtNum(ctx.raw)}}}}}}); + var modelChartEl = document.getElementById('modelChart'); + if (modelChartEl) { + var mdLabels=md.map(function(m){return m.model||'unknown'}); + var mdData=md.map(function(m){return m.tokens}); + charts.model=new Chart(modelChartEl.getContext('2d'),{type:'doughnut',data:{labels:mdLabels,datasets:[{data:mdData,backgroundColor:CHART_COLORS.slice(0,md.length),borderWidth:0,hoverOffset:6}]},options:{responsive:false,cutout:'65%',plugins:{legend:{position:'bottom',labels:{color:t.text,padding:10,font:{size:11},usePointStyle:true,pointStyle:'circle'}},tooltip:{backgroundColor:t.ttBg,titleColor:t.ttTitle,bodyColor:t.ttBody,borderColor:t.ttBorder,borderWidth:1,padding:10,cornerRadius:8,callbacks:{label:function(ctx){return ctx.label+': '+fmtNum(ctx.raw)}}}}}}); + } } // 账号排行 var ar=ov.accountRanking||[]; - document.getElementById('accountRanking').innerHTML=ar.length?ar.map(function(a,i){var maxTok=ar[0].tokens||1;var pct=Math.round(a.tokens/maxTok*100);var rankClass=i<3?'rank-'+(i+1):'rank-n';return'
'+(i+1)+''+(a.name||'-')+'
'+fmtNum(a.tokens)+'
'}).join(''):'

暂无数据

'; + var accountRankingEl = document.getElementById('accountRanking'); + if (accountRankingEl) { + accountRankingEl.innerHTML=ar.length?ar.map(function(a,i){var maxTok=ar[0].tokens||1;var pct=Math.round(a.tokens/maxTok*100);var rankClass=i<3?'rank-'+(i+1):'rank-n';return'
'+(i+1)+''+(a.name||'-')+'
'+fmtNum(a.tokens)+'
'}).join(''):'

暂无数据

'; + } // API Key 排行 var kr=ov.apiKeyRanking||[]; - document.getElementById('apiKeyRanking').innerHTML=kr.length?kr.map(function(k,i){var maxTok=kr[0].tokens||1;var pct=Math.round(k.tokens/maxTok*100);var name=k.name&&k.name.length>18?k.name.slice(0,18)+'...':(k.name||'-');var rankClass=i<3?'rank-'+(i+1):'rank-n';return'
'+(i+1)+''+name+'
'+fmtNum(k.tokens)+'
'}).join(''):'

暂无数据

'; + var apiKeyRankingEl = document.getElementById('apiKeyRanking'); + if (apiKeyRankingEl) { + apiKeyRankingEl.innerHTML=kr.length?kr.map(function(k,i){var maxTok=kr[0].tokens||1;var pct=Math.round(k.tokens/maxTok*100);var name=k.name&&k.name.length>18?k.name.slice(0,18)+'...':(k.name||'-');var rankClass=i<3?'rank-'+(i+1):'rank-n';return'
'+(i+1)+''+name+'
'+fmtNum(k.tokens)+'
'}).join(''):'

暂无数据

'; + } // 每小时分布 var hd=ov.hourlyDist||[]; destroyChart('hourly'); - var hourMap=new Map(hd.map(function(h){return[h.hour,h.requests]})); - var hourLabels=Array.from({length:24},function(_,i){return i+':00'}); - var hourData=Array.from({length:24},function(_,i){return hourMap.get(i)||0}); - charts.hourly=new Chart(document.getElementById('hourlyChart').getContext('2d'),{type:'bar',data:{labels:hourLabels,datasets:[{label:'请求数',data:hourData,backgroundColor:'rgba(129,140,248,.5)',hoverBackgroundColor:'rgba(129,140,248,.8)',borderRadius:4,borderSkipped:false}]},options:{responsive:true,plugins:{legend:{display:false},tooltip:{callbacks:{label:function(ctx){return ctx.raw+' 次请求'}}}},scales:{x:{ticks:{color:t.text,maxRotation:0,autoSkip:true,maxTicksLimit:12,font:{size:10}},grid:{display:false},border:{display:false}},y:{ticks:{color:t.text,font:{size:10}},grid:{color:t.grid},border:{display:false}}}}}); + var hourlyChartEl = document.getElementById('hourlyChart'); + if (hourlyChartEl) { + var hourMap=new Map(hd.map(function(h){return[h.hour,h.requests]})); + var hourLabels=Array.from({length:24},function(_,i){return i+':00'}); + var hourData=Array.from({length:24},function(_,i){return hourMap.get(i)||0}); + charts.hourly=new Chart(hourlyChartEl.getContext('2d'),{type:'bar',data:{labels:hourLabels,datasets:[{label:'请求数',data:hourData,backgroundColor:'rgba(129,140,248,.5)',hoverBackgroundColor:'rgba(129,140,248,.8)',borderRadius:4,borderSkipped:false}]},options:{responsive:true,plugins:{legend:{display:false},tooltip:{backgroundColor:t.ttBg,titleColor:t.ttTitle,bodyColor:t.ttBody,borderColor:t.ttBorder,borderWidth:1,padding:10,cornerRadius:8,callbacks:{label:function(ctx){return ctx.raw+' 次请求'}}}},scales:{x:{ticks:{color:t.text,maxRotation:0,autoSkip:true,maxTicksLimit:12,font:{size:10}},grid:{display:false},border:{display:false}},y:{ticks:{color:t.text,font:{size:10}},grid:{color:t.grid},border:{display:false}}}}}); + } - await loadStatsTable(); - await loadStatsApiKeys(statsApiKeyPage); + loadStatsTable(); + loadStatsApiKeys(statsApiKeyPage); +} +async function loadStats(){ + var cacheKey = 'stats'; + var cached = getCache(cacheKey); + if (cached) renderStats(cached); + try { + var ov=await api('/admin/stats/overview'); + setCache(cacheKey, ov); + renderStats(ov); + } catch (e) { + console.error(e); + } } -async function loadStatsTable(page){ - if(page)statsAccountPage=page; - var s=await api('/admin/stats?page='+statsAccountPage+'&limit=10'); +function renderStatsTable(s){ var accs=s.accounts||[]; - document.getElementById('statsTableAccountBody').innerHTML=accs.map(function(a){return ''+((a.alias||'-'))+''+a.total_requests+''+((a.total_prompt_tokens||0).toLocaleString())+''+((a.total_completion_tokens||0).toLocaleString())+''+(((a.total_prompt_tokens||0)+(a.total_completion_tokens||0)).toLocaleString())+''}).join(''); + var tbody = document.getElementById('statsTableAccountBody'); + if (tbody) { + tbody.innerHTML=accs.map(function(a){return ''+((a.alias||'-'))+''+a.total_requests+''+((a.total_prompt_tokens||0).toLocaleString())+''+((a.total_completion_tokens||0).toLocaleString())+''+(((a.total_prompt_tokens||0)+(a.total_completion_tokens||0)).toLocaleString())+''}).join(''); + } renderPagination('statsAccountPagination',s.totalAccounts,s.limit,statsAccountPage,'loadStatsTable'); var t=chartTheme();destroyChart('token'); if(accs.length){ - var labels=accs.map(function(a){return a.alias||a.api_key.slice(0,12)}); - charts.token=new Chart(document.getElementById('tokenChart').getContext('2d'),{type:'bar',data:{labels:labels,datasets:[{label:'输入 Token',data:accs.map(function(a){return a.total_prompt_tokens||0}),backgroundColor:'rgba(129,140,248,.6)',borderRadius:4},{label:'输出 Token',data:accs.map(function(a){return a.total_completion_tokens||0}),backgroundColor:'rgba(52,211,153,.6)',borderRadius:4}]},options:{responsive:true,plugins:{legend:{labels:{color:t.text,boxWidth:12,boxHeight:8,padding:16,usePointStyle:true,pointStyle:'rectRounded'}}},scales:{x:{ticks:{color:t.text,font:{size:11}},grid:{display:false},border:{display:false}},y:{ticks:{color:t.text,callback:function(v){return fmtNum(v)},font:{size:11}},grid:{color:t.grid},border:{display:false}}}}}); + var tokenChartEl = document.getElementById('tokenChart'); + if (tokenChartEl) { + var labels=accs.map(function(a){return a.alias||a.api_key.slice(0,12)}); + charts.token=new Chart(tokenChartEl.getContext('2d'),{type:'bar',data:{labels:labels,datasets:[{label:'输入 Token',data:accs.map(function(a){return a.total_prompt_tokens||0}),backgroundColor:'rgba(129,140,248,.6)',borderRadius:4},{label:'输出 Token',data:accs.map(function(a){return a.total_completion_tokens||0}),backgroundColor:'rgba(52,211,153,.6)',borderRadius:4}]},options:{responsive:true,plugins:{legend:{labels:{color:t.text,boxWidth:12,boxHeight:8,padding:16,usePointStyle:true,pointStyle:'rectRounded'}}},scales:{x:{ticks:{color:t.text,font:{size:11}},grid:{display:false},border:{display:false}},y:{ticks:{color:t.text,callback:function(v){return fmtNum(v)},font:{size:11}},grid:{color:t.grid},border:{display:false}}}}}); + } + } +} +async function loadStatsTable(page){ + if(page)statsAccountPage=page; + var cacheKey = 'stats_table_' + statsAccountPage; + var cached = getCache(cacheKey); + if (cached) renderStatsTable(cached); + try { + var s=await api('/admin/stats?page='+statsAccountPage+'&limit=10'); + setCache(cacheKey, s); + renderStatsTable(s); + } catch (e) { + console.error(e); } } -async function loadStatsApiKeys(page){if(page)statsApiKeyPage=page;var d=await api('/admin/stats/api-keys?page='+statsApiKeyPage+'&limit=10');var keys=d.apiKeys||[];document.getElementById('statsTableApiKeyBody').innerHTML=keys.map(function(k){return ''+((k.name||'-'))+''+((k.total_requests||0))+''+((k.total_prompt_tokens||0).toLocaleString())+''+((k.total_completion_tokens||0).toLocaleString())+''+(((k.total_prompt_tokens||0)+(k.total_completion_tokens||0)).toLocaleString())+''+(k.last_used_at?new Date(k.last_used_at).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'-')+''}).join('');renderPagination('statsApiKeyPagination',d.total,d.limit,statsApiKeyPage,'loadStatsApiKeys')} +function renderStatsApiKeys(d){ + var keys=d.apiKeys||[]; + var tbody = document.getElementById('statsTableApiKeyBody'); + if (tbody) { + tbody.innerHTML=keys.map(function(k){return ''+((k.name||'-'))+''+((k.total_requests||0))+''+((k.total_prompt_tokens||0).toLocaleString())+''+((k.total_completion_tokens||0).toLocaleString())+''+(((k.total_prompt_tokens||0)+(k.total_completion_tokens||0)).toLocaleString())+''+(k.last_used_at?new Date(k.last_used_at).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'-')+''}).join(''); + } + renderPagination('statsApiKeyPagination',d.total,d.limit,statsApiKeyPage,'loadStatsApiKeys'); +} +async function loadStatsApiKeys(page){ + if(page)statsApiKeyPage=page; + var cacheKey = 'stats_apikeys_' + statsApiKeyPage; + var cached = getCache(cacheKey); + if (cached) renderStatsApiKeys(cached); + try { + var d=await api('/admin/stats/api-keys?page='+statsApiKeyPage+'&limit=10'); + setCache(cacheKey, d); + renderStatsApiKeys(d); + } catch (e) { + console.error(e); + } +} function switchStatsTab(tab){if(tab==='account'){document.getElementById('statsTabAccount').className='btn btn-primary btn-sm';document.getElementById('statsTabApiKey').className='btn btn-outline btn-sm';document.getElementById('statsTableAccount').classList.remove('hidden');document.getElementById('statsTableApiKey').classList.add('hidden')}else{document.getElementById('statsTabAccount').className='btn btn-outline btn-sm';document.getElementById('statsTabApiKey').className='btn btn-primary btn-sm';document.getElementById('statsTableAccount').classList.add('hidden');document.getElementById('statsTableApiKey').classList.remove('hidden')}} var logPage=1; -async function loadLogs(page){page=page||1;logPage=page;var status=document.getElementById('logStatus').value;var endpoint=document.getElementById('logEndpoint').value;var url='/admin/logs?page='+page+'&limit=50';if(status)url+='&status='+status;if(endpoint)url+='&endpoint='+endpoint;var d=await api(url);var logs=d.logs||[];document.getElementById('logTable').innerHTML=logs.length?logs.map(function(l){return ''+((l.created_at||''))+''+l.endpoint+''+((l.model||'-'))+''+((l.prompt_tokens??'-'))+''+((l.completion_tokens??'-'))+''+((l.duration_ms??'-'))+'ms'+(l.status==='success'?'成功':'失败')+''+(l.error?''+l.error.slice(0,30)+'':'')+''}).join(''):'

暂无日志数据

';var totalPages=Math.ceil((d.total||0)/50);renderPagination('logPagination',d.total||0,50,logPage,'loadLogs')} +function renderLogs(d){ + var logs=d.logs||[]; + var logTableEl = document.getElementById('logTable'); + if (logTableEl) { + logTableEl.innerHTML=logs.length?logs.map(function(l){return ''+((l.created_at||''))+''+l.endpoint+''+((l.model||'-'))+''+((l.prompt_tokens??'-'))+''+((l.completion_tokens??'-'))+''+((l.duration_ms??'-'))+'ms'+(l.status==='success'?'成功':'失败')+''+(l.error?''+l.error.slice(0,30)+'':'')+''}).join(''):'

暂无日志数据

'; + } + renderPagination('logPagination',d.total||0,50,logPage,'loadLogs'); +} +async function loadLogs(page){ + page=page||1; + logPage=page; + var status=document.getElementById('logStatus').value; + var endpoint=document.getElementById('logEndpoint').value; + var cacheKey = 'logs_' + page + '_' + status + '_' + endpoint; + var cached = getCache(cacheKey); + if (cached) renderLogs(cached); + + var url='/admin/logs?page='+page+'&limit=50'; + if(status)url+='&status='+status; + if(endpoint)url+='&endpoint='+endpoint; + try { + var d=await api(url); + setCache(cacheKey, d); + renderLogs(d); + } catch (e) { + console.error(e); + if (!cached) { + var logTableEl = document.getElementById('logTable'); + if (logTableEl) { + logTableEl.innerHTML='

加载失败,请检查网络

'; + } + } + } +} -async function loadSessions(){var data=await api('/admin/sessions');var sessions=data||[];document.getElementById('sessionTable').innerHTML=sessions.length?sessions.map(function(s){return ''+s.id.slice(0,16)+'...'+s.account_id.slice(0,12)+'...'+s.client_session_id+''+((s.cumulative_prompt_tokens||0).toLocaleString())+''+((s.last_used_at||''))+''}).join(''):'

暂无会话数据

'} +function renderSessions(sessions){ + var sessionTableEl = document.getElementById('sessionTable'); + if (sessionTableEl) { + sessionTableEl.innerHTML=sessions.length?sessions.map(function(s){return ''+s.id.slice(0,16)+'...'+s.account_id.slice(0,12)+'...'+s.client_session_id+''+((s.cumulative_prompt_tokens||0).toLocaleString())+''+((s.last_used_at||''))+'
'}).join(''):'

暂无会话数据

'; + } +} +async function loadSessions(){ + var cacheKey = 'sessions'; + var cached = getCache(cacheKey); + if (cached) renderSessions(cached); + try { + var data=await api('/admin/sessions'); + var sessions=data||[]; + setCache(cacheKey, sessions); + renderSessions(sessions); + } catch (e) { + console.error(e); + if (!cached) { + var sessionTableEl = document.getElementById('sessionTable'); + if (sessionTableEl) { + sessionTableEl.innerHTML = '

加载失败,请检查网络

'; + } + } + } +} async function deleteSession(id){var ok=await showModal({title:'删除会话',message:'确认删除此会话?',dangerous:true,confirmText:'删除'});if(!ok)return;await api('/admin/sessions/'+id,{method:'DELETE'});toast('已删除');loadSessions()} -// API Keys -async function loadApiKeys(){var data=await api('/admin/api-keys');var keys=data&&data.keys?data.keys:[];var tbody=document.getElementById('apiKeyTable');if(!keys.length){tbody.innerHTML='

暂无 API 密钥

点击右上角"创建新密钥"开始

';return}tbody.innerHTML=keys.map(function(k){var masked=k.key.slice(0,7)+'****...****'+k.key.slice(-4);return ''+((k.name||'-'))+'
'+masked+'
'+(k.is_active?'启用':'禁用')+''+((k.request_count||0))+''+(k.last_used_at?new Date(k.last_used_at).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'-')+'
'}).join('')} +function renderApiKeys(data){ + var keys=data&&data.keys?data.keys:[]; + var tbody=document.getElementById('apiKeyTable'); + if(!tbody) return; + if(!keys.length){tbody.innerHTML='

暂无 API 密钥

点击右上角"创建新密钥"开始

';return}tbody.innerHTML=keys.map(function(k){var masked=k.key.slice(0,7)+'****...****'+k.key.slice(-4);return ''+((k.name||'-'))+'
'+masked+'
'+(k.is_active?'启用':'禁用')+''+((k.request_count||0))+''+(k.last_used_at?new Date(k.last_used_at).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'-')+'
'}).join('') +} +async function loadApiKeys(){ + var cacheKey = 'apikeys'; + var cached = getCache(cacheKey); + if (cached) renderApiKeys(cached); + try { + var data = await api('/admin/api-keys'); + setCache(cacheKey, data); + renderApiKeys(data); + } catch (e) { + console.error(e); + if (!cached) { + document.getElementById('apiKeyTable').innerHTML = '

加载失败,请检查网络

'; + } + } +} async function createApiKey(){var result=await showModal({title:'创建 API 密钥',message:'填写以下信息(均可留空)',type:'prompt',placeholder:'密钥名称(可选)',placeholder2:'自定义密钥,留空自动生成(如 sk-xxx)',label2:'自定义密钥(可选)'});if(result===null)return;var name=result[0],customKey=result[1];var trimmedKey=(customKey||'').trim();var body={name:(name||'').trim()||undefined,key:trimmedKey===''?undefined:trimmedKey};var res=await api('/admin/api-keys',{method:'POST',body:body});if(res.key){toast('密钥创建成功');loadApiKeys()}else{toast(res.error||'创建失败',false)}} function toggleKeyVisibility(btn){var code=btn.closest('td').querySelector('.key-display');var fullKey=code.dataset.key;var masked=code.dataset.masked;if(code.textContent===masked){code.textContent=fullKey;code.style.color='var(--c-text)'}else{code.textContent=masked;code.style.color='var(--c-text-4)'}} function copyApiKey(btn){var code=btn.closest('td').querySelector('.key-display');navigator.clipboard.writeText(code.dataset.key).then(function(){toast('已复制 API 密钥')})} @@ -542,11 +809,178 @@

'+label+'
无数据

'; + var formatted = body; + try { formatted = JSON.stringify(JSON.parse(body), null, 2); } catch(e) { /* keep as-is */ } + return '
' + + '
'+label+'
' + + '
' + + '' + + ' 展开查看
' + + '' + + '
'; +} +function toggleDetailBody(id) { + var el = document.getElementById(id); + if (!el) return; + var toggle = el.previousElementSibling; + if (el.classList.contains('hidden')) { + el.classList.remove('hidden'); + toggle.innerHTML = ' 收起'; + } else { + el.classList.add('hidden'); + toggle.innerHTML = ' 展开查看'; + } +} +async function viewLogDetail(id) { + try { + var log = await api('/admin/logs/' + id); + if (log.error) { toast(log.error, false); return; } + var html = ''; + html += '
'; + html += '
请求信息
'; + html += '
'; + html += '
请求 ID'+escHtml(log.id)+'
'; + html += '
时间'+escHtml(log.created_at||'-')+'
'; + html += '
端点'+escHtml(log.endpoint||'-')+'
'; + html += '
模型'+escHtml(log.model||'-')+'
'; + html += '
状态'+(log.status==='success'?'成功':'失败')+'
'; + html += '
耗时'+((log.duration_ms!=null)?log.duration_ms+'ms':'-')+'
'; + html += '
账号 ID'+escHtml(log.account_id||'-')+'
'; + html += '
会话 ID'+escHtml(log.session_id||'-')+'
'; + html += '
API Key ID'+escHtml(log.api_key_id||'-')+'
'; + html += '
'; + html += '
'; + html += '
Token 统计
'; + html += '
'; + html += '
输入 Token'+((log.prompt_tokens!=null)?log.prompt_tokens.toLocaleString():'-')+'
'; + html += '
输出 Token'+((log.completion_tokens!=null)?log.completion_tokens.toLocaleString():'-')+'
'; + html += '
推理 Token'+((log.reasoning_tokens!=null)?log.reasoning_tokens.toLocaleString():'-')+'
'; + html += '
总 Token'+(((log.prompt_tokens||0)+(log.completion_tokens||0)+(log.reasoning_tokens||0)).toLocaleString())+'
'; + html += '
'; + if (log.error) { + html += '
'; + html += '
错误信息
'; + html += '
'+escHtml(log.error)+'
'; + html += '
'; + } + html += formatJsonBody(log.request_body, '请求体 (Request Body)', 'logReqBody'); + html += formatJsonBody(log.response_body, '响应体 (Response Body)', 'logResBody'); + openDetailModal('请求日志详情', html); + } catch(e) { toast('加载详情失败', false); } +} +async function viewSessionDetail(id) { + try { + var data = await api('/admin/sessions/' + id); + if (data.error) { toast(data.error, false); return; } + var html = ''; + html += '
'; + html += '
会话信息
'; + html += '
'; + html += '
会话 ID'+escHtml(data.id)+'
'; + html += '
客户端 Key'+escHtml(data.client_session_id||'-')+'
'; + html += '
对话 ID'+escHtml(data.conversation_id||'-')+'
'; + html += '
过期状态'+(data.is_expired?'已过期':'活跃')+'
'; + html += '
累计输入 Token'+((data.cumulative_prompt_tokens||0).toLocaleString())+'
'; + html += '
创建时间'+escHtml(data.created_at||'-')+'
'; + html += '
最后使用'+escHtml(data.last_used_at||'-')+'
'; + html += '
消息指纹'+escHtml(data.last_message_fingerprint||'-')+'
'; + html += '
'; + if (data.account) { + html += '
'; + html += '
关联账号
'; + html += '
'; + html += '
别名'+escHtml(data.account.alias||'-')+'
'; + html += '
用户 ID'+escHtml(data.account.user_id||'-')+'
'; + html += '
账号 ID'+escHtml(data.account.id||'-')+'
'; + html += '
'; + } + var logs = data.logs || []; + html += '
'; + html += '
关联请求日志 (最近 '+logs.length+' 条)
'; + if (logs.length === 0) { + html += '暂无请求记录'; + } else { + logs.forEach(function(l) { + var statusColor = l.status === 'success' ? 'var(--c-accent-green)' : 'var(--c-accent-red)'; + html += '
'; + html += ''+escHtml((l.created_at||'').slice(5,16))+''; + html += ''+escHtml(l.endpoint||'-')+''; + html += ''+escHtml(l.model||'-')+''; + html += ''+((l.prompt_tokens!=null)?l.prompt_tokens:'-')+''; + html += ''+((l.completion_tokens!=null)?l.completion_tokens:'-')+''; + html += ''+(l.status==='success'?'成功':'失败')+''; + html += ''; + html += '
'; + }); + } + html += '
'; + openDetailModal('会话详情', html); + } catch(e) { toast('加载详情失败', false); } +} + +// Keyboard shortcut: ESC to close detail modal +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') closeDetailModal(); +}); + +ensureAuth().then(function(){clearAdminCache();loadAccounts()}); + +function renderSettings(cfg){ + var portEl = document.getElementById('cfgPort'); + if (portEl) portEl.value=cfg.port||''; + var maxConcurrentEl = document.getElementById('cfgMaxConcurrent'); + if (maxConcurrentEl) maxConcurrentEl.value=cfg.maxConcurrentPerAccount||''; + var maxReplayEl = document.getElementById('cfgMaxReplay'); + if (maxReplayEl) maxReplayEl.value=cfg.maxReplayMessages||''; + var maxQueryEl = document.getElementById('cfgMaxQuery'); + if (maxQueryEl) maxQueryEl.value=cfg.maxQueryChars||''; + var contextResetEl = document.getElementById('cfgContextReset'); + if (contextResetEl) contextResetEl.value=cfg.contextResetThreshold||''; + var sessionTtlEl = document.getElementById('cfgSessionTtl'); + if (sessionTtlEl) sessionTtlEl.value=cfg.sessionTtlDays||''; + var thinkModeEl = document.getElementById('cfgThinkMode'); + if (thinkModeEl) thinkModeEl.value=cfg.thinkMode||'passthrough'; + var sessionIsolationEl = document.getElementById('cfgSessionIsolation'); + if (sessionIsolationEl) sessionIsolationEl.value=cfg.sessionIsolation||'auto'; + var mimoProxyEl = document.getElementById('cfgMimoProxy'); + if (mimoProxyEl) mimoProxyEl.value=cfg.mimoProxy||''; +} +async function loadSettings(){ + updateThemeBtns(); + var cacheKey = 'settings'; + var cached = getCache(cacheKey); + if (cached) renderSettings(cached); + try{ + var cfg=await api('/admin/config'); + setCache(cacheKey, cfg); + renderSettings(cfg); + }catch(e){ + console.error(e); + toast('加载配置失败',false) + } +} -// Settings -async function loadSettings(){updateThemeBtns();try{var cfg=await api('/admin/config');document.getElementById('cfgPort').value=cfg.port||'';document.getElementById('cfgMaxConcurrent').value=cfg.maxConcurrentPerAccount||'';document.getElementById('cfgMaxReplay').value=cfg.maxReplayMessages||'';document.getElementById('cfgMaxQuery').value=cfg.maxQueryChars||'';document.getElementById('cfgContextReset').value=cfg.contextResetThreshold||'';document.getElementById('cfgSessionTtl').value=cfg.sessionTtlDays||'';document.getElementById('cfgThinkMode').value=cfg.thinkMode||'passthrough';document.getElementById('cfgSessionIsolation').value=cfg.sessionIsolation||'auto'}catch(e){toast('加载配置失败',false)}} -async function saveSettings(){var body={maxConcurrentPerAccount:Number(document.getElementById('cfgMaxConcurrent').value),maxReplayMessages:Number(document.getElementById('cfgMaxReplay').value),maxQueryChars:Number(document.getElementById('cfgMaxQuery').value),contextResetThreshold:Number(document.getElementById('cfgContextReset').value),sessionTtlDays:Number(document.getElementById('cfgSessionTtl').value),thinkMode:document.getElementById('cfgThinkMode').value,sessionIsolation:document.getElementById('cfgSessionIsolation').value};try{await api('/admin/config',{method:'PATCH',body:body});toast('配置已保存')}catch(e){toast('保存失败',false)}} +async function saveSettings(){var body={maxConcurrentPerAccount:Number(document.getElementById('cfgMaxConcurrent').value),maxReplayMessages:Number(document.getElementById('cfgMaxReplay').value),maxQueryChars:Number(document.getElementById('cfgMaxQuery').value),contextResetThreshold:Number(document.getElementById('cfgContextReset').value),sessionTtlDays:Number(document.getElementById('cfgSessionTtl').value),thinkMode:document.getElementById('cfgThinkMode').value,sessionIsolation:document.getElementById('cfgSessionIsolation').value,mimoProxy:document.getElementById('cfgMimoProxy').value.trim()};try{var res=await api('/admin/config',{method:'PATCH',body:body});if(res.error){toast(res.error,false);return}toast('配置已保存')}catch(e){toast('保存失败',false)}} +async function testMimoProxy(){var proxy=document.getElementById('cfgMimoProxy').value.trim();if(!proxy){toast('请先填写并保存 MiMo 专用代理',false);return}var cfg=await api('/admin/config');if((cfg.mimoProxy||'')!==proxy){toast('请先保存 MiMo 代理配置,再测试',false);return}var btn=document.getElementById('testMimoProxyBtn'),oldText=btn.textContent;btn.disabled=true;btn.textContent='测试中...';try{var result=await api('/admin/mimo-proxy/test',{method:'POST'});if(result.success){toast('MiMo 代理可用:'+result.latency+'ms,模型 '+result.models+' 个')}else{toast(result.error||'MiMo 代理测试失败',false)}}catch(e){toast('测试失败',false)}finally{btn.disabled=false;btn.textContent=oldText}} // Health async function checkHealth(){try{var r=await fetch('/health');var el=document.getElementById('wsStatus');if(r.ok){el.innerHTML=' 已连接';el.style.color=''}else{el.innerHTML=' 异常';el.style.color=''}}catch(e){var el2=document.getElementById('wsStatus');el2.innerHTML=' 断开';el2.style.color=''}} diff --git a/src/web/input.css b/src/web/input.css old mode 100644 new mode 100755 diff --git a/src/web/style.css b/src/web/style.css old mode 100644 new mode 100755 index 20827fd..ddb1264 --- a/src/web/style.css +++ b/src/web/style.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-slate-200:oklch(92.9% .013 255.508);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--tracking-wider:.05em;--radius-sm:.25rem;--radius-lg:.5rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.static{position:static}.inset-0{inset:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.z-50{z-index:50}.z-\[100\]{z-index:100}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.table{display:table}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-screen{height:100vh}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-28{width:calc(var(--spacing) * 28)}.w-56{width:calc(var(--spacing) * 56)}.w-full{width:100%}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.resize{resize:both}.resize-y{resize:vertical}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded-lg{border-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.bg-\[\#0b0d14\]{background-color:#0b0d14}.p-6{padding:calc(var(--spacing) * 6)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-5{padding-block:calc(var(--spacing) * 5)}.font-mono{font-family:var(--font-mono)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-slate-200{color:var(--color-slate-200)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.opacity-50{opacity:.5}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}@media (min-width:40rem){.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:48rem){.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:px-8{padding-inline:calc(var(--spacing) * 8)}.md\:py-6{padding-block:calc(var(--spacing) * 6)}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-slate-200:oklch(92.9% .013 255.508);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--tracking-wider:.05em;--radius-sm:.25rem;--radius-lg:.5rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.static{position:static}.inset-0{inset:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.z-50{z-index:50}.z-\[100\]{z-index:100}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-screen{height:100vh}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-56{width:calc(var(--spacing) * 56)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.resize{resize:both}.resize-y{resize:vertical}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded-lg{border-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.bg-\[\#0b0d14\]{background-color:#0b0d14}.p-1{padding:calc(var(--spacing) * 1)}.p-6{padding:calc(var(--spacing) * 6)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-5{padding-block:calc(var(--spacing) * 5)}.font-mono{font-family:var(--font-mono)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-slate-200{color:var(--color-slate-200)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.opacity-50{opacity:.5}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}@media (min-width:40rem){.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:48rem){.md\:col-span-2{grid-column:span 2/span 2}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:px-8{padding-inline:calc(var(--spacing) * 8)}.md\:py-6{padding-block:calc(var(--spacing) * 6)}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file diff --git a/test/openai-normalize.test.ts b/test/openai-normalize.test.ts new file mode 100644 index 0000000..6090bf5 --- /dev/null +++ b/test/openai-normalize.test.ts @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeOpenAIRequestBody } from '../src/adapters/openai-normalize.ts'; + +test('keeps chat completion messages and converts developer role to system', () => { + const normalized = normalizeOpenAIRequestBody({ + model: 'mimo-v2-pro', + messages: [ + { role: 'developer', content: 'answer briefly' }, + { role: 'user', content: 'hello' }, + ], + }); + + assert.deepEqual(normalized.messages, [ + { role: 'system', content: 'answer briefly' }, + { role: 'user', content: 'hello' }, + ]); + assert.equal(normalized.sessionKey, null); +}); + +test('converts Responses string input into a user message', () => { + const normalized = normalizeOpenAIRequestBody({ + model: 'mimo-v2-pro', + instructions: 'be concise', + input: 'hello', + conversation: 'conv_123', + }); + + assert.deepEqual(normalized.messages, [ + { role: 'system', content: 'be concise' }, + { role: 'user', content: 'hello' }, + ]); + assert.equal(normalized.sessionKey, 'conversation:conv_123'); +}); + +test('keeps chat completion multimodal content arrays for image extraction', () => { + const content = [ + { type: 'text', text: 'look' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } }, + ]; + + const normalized = normalizeOpenAIRequestBody({ + messages: [{ role: 'user', content }], + }); + + assert.equal(normalized.messages[0].content, content); +}); + +test('converts Responses message input items and text content parts', () => { + const normalized = normalizeOpenAIRequestBody({ + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'first' }, + { type: 'input_text', text: 'second' }, + ], + }, + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'answer' }], + }, + ], + previous_response_id: 'resp_123', + }); + + assert.deepEqual(normalized.messages, [ + { role: 'user', content: 'first\nsecond' }, + { role: 'assistant', content: 'answer' }, + ]); + assert.equal(normalized.sessionKey, 'previous_response:resp_123'); +}); + +test('rejects request bodies without messages or input', () => { + assert.throws( + () => normalizeOpenAIRequestBody({ model: 'mimo-v2-pro' }), + /messages or input/ + ); +}); diff --git a/test/session-marker.test.ts b/test/session-marker.test.ts new file mode 100644 index 0000000..0add5b5 --- /dev/null +++ b/test/session-marker.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateClientSessionId } from '../src/mimo/session-marker.ts'; + +function fakeContext(headers: Record) { + return { + req: { + header(name: string) { + return headers[name.toLowerCase()] ?? headers[name] ?? undefined; + }, + }, + } as any; +} + +test('uses explicit request session key before header or isolation fallback', () => { + const id = generateClientSessionId( + fakeContext({ 'x-session-id': 'header-session' }), + 'acct1', + 'conversation:conv_123', + 'auto' + ); + assert.equal(id, 'explicit_acct1_conversation:conv_123'); +}); + +test('manual isolation uses x-session-id when supplied', () => { + const id = generateClientSessionId(fakeContext({ 'x-session-id': 'manual-1' }), 'acct1', null, 'manual'); + assert.equal(id, 'explicit_acct1_manual-1'); +}); + +test('per-request isolation generates a different session id each time', () => { + const c = fakeContext({}); + const first = generateClientSessionId(c, 'acct1', null, 'per-request'); + const second = generateClientSessionId(c, 'acct1', null, 'per-request'); + assert.notEqual(first, second); + assert.match(first, /^request_acct1_/); + assert.match(second, /^request_acct1_/); +}); + +test('auto isolation includes client network and user agent hints', () => { + const first = generateClientSessionId( + fakeContext({ 'x-forwarded-for': '203.0.113.8', 'user-agent': 'client-a' }), + 'acct1', + null, + 'auto' + ); + const second = generateClientSessionId( + fakeContext({ 'x-forwarded-for': '203.0.113.9', 'user-agent': 'client-a' }), + 'acct1', + null, + 'auto' + ); + assert.notEqual(first, second); + assert.match(first, /^auto_acct1_/); +}); diff --git a/tsconfig.json b/tsconfig.json old mode 100644 new mode 100755