From d205289f9f6be4a356ed60ffc86317cb515b3278 Mon Sep 17 00:00:00 2001 From: lsy Date: Mon, 16 Mar 2026 22:13:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=20usage=20=E9=98=9F?= =?UTF-8?q?=E5=88=97=E4=B8=8E=E6=9C=AC=E5=9C=B0=E9=83=A8=E7=BD=B2=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usage 队列异步写入与流式解析限流 - 管理台展示运行时配置 - 本地 dev/部署脚本与 CI 队列创建 --- .github/workflows/deploy.yml | 34 ++ .helloagents/CHANGELOG.md | 54 ++++ .helloagents/modules/admin-ui.md | 1 + .helloagents/modules/deploy-workflow.md | 12 + .helloagents/modules/proxy.md | 5 + .helloagents/modules/settings.md | 3 + .helloagents/modules/usage.md | 2 + README.md | 34 ++ apps/ui/src/App.tsx | 60 +++- apps/ui/src/core/types.ts | 10 + apps/ui/src/features/AppLayout.tsx | 55 ++-- apps/ui/src/features/SettingsView.tsx | 325 +++++++++++--------- apps/ui/src/features/SitesView.tsx | 2 +- apps/ui/src/features/TokensView.tsx | 2 +- apps/ui/src/features/UsageView.tsx | 24 +- apps/ui/src/styles.css | 5 + apps/worker/src/env.ts | 7 + apps/worker/src/index.ts | 6 +- apps/worker/src/routes/proxy.ts | 293 ++++++++++++------ apps/worker/src/routes/settings.ts | 2 + apps/worker/src/services/settings.ts | 68 ++++ apps/worker/src/services/usage-queue.ts | 89 ++++++ apps/worker/src/utils/usage.ts | 46 +++ apps/worker/wrangler.toml | 14 + package.json | 4 + scripts/deploy.mjs | 392 ++++++++++++++++++++++++ scripts/dev.mjs | 80 +++++ 27 files changed, 1338 insertions(+), 291 deletions(-) create mode 100644 apps/worker/src/services/usage-queue.ts create mode 100644 scripts/deploy.mjs create mode 100644 scripts/dev.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 779325a..be796fd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -260,6 +260,23 @@ jobs: if: steps.check-db.outputs.db_exists != 'true' run: bunx wrangler telemetry disable + - name: Create or verify Queue + if: steps.check-db.outputs.db_exists != 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + echo "检查并创建 Queue..." + QUEUE_LIST=$(bunx wrangler queues list --json --config apps/worker/wrangler.toml 2>/dev/null || echo "[]") + EXISTING_QUEUE=$(echo "$QUEUE_LIST" | jq -r '.[] | select(.name=="usage-events") | .name') + + if [ -n "$EXISTING_QUEUE" ]; then + echo "✅ Queue 已存在: usage-events" + else + echo "创建 Queue: usage-events" + bunx wrangler queues create usage-events --config apps/worker/wrangler.toml + fi + - name: Create D1 database (init only) if: steps.check-db.outputs.db_exists != 'true' env: @@ -359,6 +376,23 @@ jobs: - name: Disable wrangler telemetry run: bunx wrangler telemetry disable + - name: Create or verify Queue + if: needs.check-config.outputs.deploy_worker == 'true' || needs.check-config.outputs.should_migrate == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + echo "检查并创建 Queue..." + QUEUE_LIST=$(bunx wrangler queues list --json --config apps/worker/wrangler.toml 2>/dev/null || echo "[]") + EXISTING_QUEUE=$(echo "$QUEUE_LIST" | jq -r '.[] | select(.name=="usage-events") | .name') + + if [ -n "$EXISTING_QUEUE" ]; then + echo "✅ Queue 已存在: usage-events" + else + echo "创建 Queue: usage-events" + bunx wrangler queues create usage-events --config apps/worker/wrangler.toml + fi + - name: Create or verify D1 Database if: needs.check-config.outputs.deploy_worker == 'true' || needs.check-config.outputs.deploy_ui == 'true' || needs.check-config.outputs.should_migrate == 'true' env: diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index e2411ab..4320d87 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -2,8 +2,20 @@ ## [Unreleased] +### 新增 + +- **[deploy-workflow]**: 新增本地一键部署脚本(init/update) — by lsy + - 方案: [202603161914_local-deploy-script](archive/2026-03/202603161914_local-deploy-script/) + ### 变更 +- **[deploy-workflow]**: 本地部署脚本改为纯本地流程(构建 + 本地迁移) — by lsy + - 方案: [202603162008_local-deploy-local-only](archive/2026-03/202603162008_local-deploy-local-only/) +- **[settings/admin-ui]**: 设置接口返回运行时配置,管理台只读展示并指引环境变量配置 + - 方案: [202603161748_settings-runtime-env-display](archive/2026-03/202603161748_settings-runtime-env-display/) +- **[deploy-workflow]**: 部署流程自动创建 `usage-events` Queue(存在则跳过) — by lsy + - 方案: [202603161714_deploy-create-queue](archive/2026-03/202603161714_deploy-create-queue/) + - 决策: deploy-create-queue#D001(CI 自动创建队列) - **[admin-ui/dashboard]**: Dashboard 样板页引入 Apple 风主题(bento grid 卡片/表格/按钮重绘) - 方案: [202603152243_apple-ui-sample-dashboard](archive/2026-03/202603152243_apple-ui-sample-dashboard/) - 文件: apps/ui/src/features/DashboardView.tsx, apps/ui/src/styles.css, .helloagents/modules/admin-ui.md, .helloagents/modules/dashboard.md @@ -20,6 +32,8 @@ ### 微调 +- **[proxy/settings]**: 流式 usage 解析上限支持 `0` 表示无限制 — by lsy + - 方案: [202603161849_stream-usage-unlimited](archive/2026-03/202603161849_stream-usage-unlimited/) - **[worker/models]**: 引入通道模型能力表并基于“模型广场测试结果+可配置TTL(默认2小时)”进行模型发布与路由匹配 - 类型: 微调(无方案包) - 文件: apps/worker/migrations/0004_add_channel_model_capabilities.sql, apps/worker/src/services/channel-model-capabilities.ts, apps/worker/src/services/channel-testing.ts, apps/worker/src/services/settings.ts, apps/worker/src/routes/settings.ts, apps/worker/src/routes/models.ts, apps/worker/src/routes/newapiUsers.ts, apps/worker/src/routes/proxy.ts, tests/worker/channel-model-capabilities.test.ts, tests/worker/newapi.test.ts, helloagents/modules/settings.md, helloagents/modules/models.md, helloagents/modules/proxy.md @@ -180,6 +194,46 @@ - 方案: [202603160049_modal-toast-search-fixes](archive/2026-03/202603160049_modal-toast-search-fixes/) - 决策: modal-toast-search-fixes#D001(通知统一为右上 Toast) +### 快速修改 + +- **[tooling]**: dev 脚本适配 bun 可执行路径解析 — by lsy + - 类型: 快速修改(无方案包) + - 文件: scripts/dev.mjs +- **[deploy-workflow]**: 本地部署脚本适配 bun 可执行路径解析 — by lsy + - 类型: 快速修改(无方案包) + - 文件: scripts/deploy.mjs +- **[tooling]**: 将 `dev:all` 简化为 `dev` 脚本 — by lsy + - 类型: 快速修改(无方案包) + - 文件: package.json, README.md +- **[deploy-workflow]**: 缺少环境变量时 .env 优先从 .env.example 生成 — by lsy + - 类型: 快速修改(无方案包) + - 文件: scripts/deploy.mjs, .helloagents/modules/deploy-workflow.md +- **[deploy-workflow]**: .env.example 注释改为中文并补充默认代理配置 — by lsy + - 类型: 快速修改(无方案包) + - 文件: .env.example +- **[deploy-workflow]**: 本地部署脚本补充 .env 占位与 .env.example 模板 — by lsy + - 类型: 快速修改(无方案包) + - 文件: scripts/deploy.mjs, .env.example, .helloagents/modules/deploy-workflow.md, README.md +- **[deploy-workflow]**: 本地部署脚本未传参时支持交互选择 — by lsy + - 类型: 快速修改(无方案包) + - 文件: scripts/deploy.mjs, README.md, .helloagents/modules/deploy-workflow.md +- **[deploy-workflow]**: 增加本地部署脚本的 package scripts 快捷入口 — by lsy + - 类型: 快速修改(无方案包) + - 文件: package.json, README.md +- **[docs]**: README 补充系统设置的运行时环境变量说明 + - 类型: 快速修改(无方案包) + - 文件: README.md +- **[admin-ui]**: 加深弹窗遮罩并支持多条通知弹窗,使用日志表头遮罩更清晰 — by lsy + - 类型: 快速修改(无方案包) + - 文件: apps/ui/src/App.tsx:129-220,1357-1510, apps/ui/src/features/AppLayout.tsx:1-195, apps/ui/src/features/SitesView.tsx:582, apps/ui/src/features/TokensView.tsx:415, apps/ui/src/features/UsageView.tsx:209-219,377, apps/ui/src/styles.css:424-440 + +## [0.9.7] - 2026-03-16 + +### 变更 +- **[proxy/usage]**: 引入 usage 队列异步写入与流式解析降载,增加 CPU 降载开关 — by lsy + - 方案: [202603161542_proxy-cpu-limit-mitigation](archive/2026-03/202603161542_proxy-cpu-limit-mitigation/) + - 决策: proxy-cpu-limit-mitigation#D001(用量写入异步化策略) + ## [0.9.6] - 2026-03-15 ### 修复 diff --git a/.helloagents/modules/admin-ui.md b/.helloagents/modules/admin-ui.md index fae6713..73848eb 100644 --- a/.helloagents/modules/admin-ui.md +++ b/.helloagents/modules/admin-ui.md @@ -33,6 +33,7 @@ - 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`) - 系统设置增加定时签到开关与时间(中国时间) - 系统设置提供模型失败冷却分钟数配置 +- 系统设置展示运行时配置(只读),提示在部署环境中修改环境变量 - 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择 - Token 仅本地保存,不回显 - 移动端使用顶部栏 + 抽屉导航,侧边栏在桌面端保持固定 diff --git a/.helloagents/modules/deploy-workflow.md b/.helloagents/modules/deploy-workflow.md index 08e1f8c..7c3c293 100644 --- a/.helloagents/modules/deploy-workflow.md +++ b/.helloagents/modules/deploy-workflow.md @@ -16,6 +16,18 @@ GitHub Actions 部署流程,负责区分 update/init 动作,控制 UI/Worker **行为**: 按变更范围选择前端/后端部署,必要时执行迁移 **结果**: 日常更新发布 +### 队列自动创建 +**条件**: 部署 Worker 或执行迁移 +**行为**: 检查 `usage-events` Queue 是否存在,不存在则创建 +**结果**: 队列资源已就绪,避免部署后运行失败 + +### 本地部署脚本 +**条件**: 本地执行 `scripts/deploy.mjs` +**行为**: 执行本地流程(构建 UI、本地迁移) +**结果**: 支持一键初始化与更新部署(不触发远程部署) +**补充**: 未指定 action 时进入交互选择(init/update、target、migrate) +**补充**: `.env` 不存在时自动从 `.env.example` 生成(或创建空文件) + ## 依赖关系 ```yaml diff --git a/.helloagents/modules/proxy.md b/.helloagents/modules/proxy.md index c9b13d3..72aba86 100644 --- a/.helloagents/modules/proxy.md +++ b/.helloagents/modules/proxy.md @@ -19,9 +19,12 @@ - done-hub 仅使用 `base_url`,不再按多地址切换 - 支持从非流式 JSON、响应头与流式 SSE(含 `response.usage` 与 `usageMetadata`)解析 usage 字段 - 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage +- 流式 usage 解析支持 `full/lite/off` 模式(`lite` 仅解析包含 usage 的事件,并可限制最大扫描字节) - 对 `/v1/responses` 且上游返回 400/404 时回退为 `/responses` 重试一次 - 可配置失败重试轮询(响应 5xx/429 时触发) - 记录流式请求标记、首 token 延迟与推理强度到 usage_logs +- 使用日志与模型能力写入可通过 `USAGE_QUEUE` 异步化(队列不可用时自动回退同步写入) +- 当 usage 解析被跳过或超限时,会记录 `usage_skipped/usage_missing` 用于可观测性 - 上游类型由 `metadata_json.site_type` 决定(`openai/anthropic/gemini`,其他类型视为 openai 兼容) - 允许 `metadata_json.model_mapping` 将下游模型映射为上游模型 - 允许 `metadata_json.endpoint_overrides` 覆盖 chat/embedding/image 上游地址(可用 `{model}` 占位) @@ -34,6 +37,8 @@ - 仅 429/5xx/408/timeout/exception 计入冷却,4xx 请求错误不触发冷却 - 映射模型场景失败时同时记录下游模型错误,确保冷却窗口生效 - 上游成功响应会刷新能力表,清空错误并延长可用窗口 +- CPU 保护开关(环境变量): `PROXY_STREAM_USAGE_MODE`, `PROXY_STREAM_USAGE_MAX_BYTES`, `PROXY_STREAM_USAGE_MAX_PARSERS`, `PROXY_USAGE_QUEUE_ENABLED` +- `PROXY_STREAM_USAGE_MAX_BYTES` 与 `PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制(未配置则使用默认值) ## 依赖关系 - `channels` / `tokens` / `usage_logs` 表 diff --git a/.helloagents/modules/settings.md b/.helloagents/modules/settings.md index 20e7728..e094c51 100644 --- a/.helloagents/modules/settings.md +++ b/.helloagents/modules/settings.md @@ -17,6 +17,9 @@ - 更新签到时间后会触发 `CheckinScheduler` 重新调度 - 若更新后当前时间已超过新签到时间,当日可再次触发自动签到 - 模型失败冷却以分钟计(默认 10 分钟),用于跳过最近失败的渠道/模型 +- 代理 CPU 降载相关开关使用环境变量(非 DB 设置) +- `GET /api/settings` 额外返回 `runtime_config`(环境变量读取结果),用于展示流式 usage 解析与队列状态 +- 运行时配置中 `PROXY_STREAM_USAGE_MAX_BYTES/PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制 ## 依赖关系 - `settings` 表 diff --git a/.helloagents/modules/usage.md b/.helloagents/modules/usage.md index 132a1be..d072850 100644 --- a/.helloagents/modules/usage.md +++ b/.helloagents/modules/usage.md @@ -10,6 +10,8 @@ ## 行为规范 - 代理调用统一写入 `usage_logs`(含无可用渠道/冷却提前返回) +- 使用日志可通过 `USAGE_QUEUE` 异步写入(可能存在短暂延迟) +- 流式 usage 解析被跳过或超限时会记录 `usage_skipped/usage_missing` 以便排查 - 保留天数可配置,查询时触发清理 - 查询结果附带渠道与令牌名称 - `GET /api/usage` 支持 `offset/limit` 分页并返回 `total` diff --git a/README.md b/README.md index 034451a..d8282e8 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ bun run dev:worker - 日志保留天数(默认 30) - 会话时长(小时,默认 12) - 管理员密码(首次登录在登录页输入密码将自动设置,可在系统设置中修改) +- 运行时配置(只读,来自环境变量):`PROXY_STREAM_USAGE_MODE`、`PROXY_STREAM_USAGE_MAX_BYTES`、`PROXY_STREAM_USAGE_MAX_PARSERS`、`PROXY_USAGE_QUEUE_ENABLED`(需在部署环境中修改) ### 3) 启动管理台 UI @@ -114,6 +115,39 @@ bun run --filter api-worker db:migrate - `bun run typecheck` - `bun run lint` - `bun run format` +- `bun run dev` 同时启动 Worker 与 UI + +## 本地一键部署脚本 + +脚本位置:`scripts/deploy.mjs`,用于在本地复刻 GitHub Actions 的 init/update 部署流程。 + +前置环境: + +- Bun(确保 `bun`/`bunx` 可用) +- Wrangler(通过 `bunx wrangler` 使用) +- `.env.example` 可作为本地环境变量模板(可选) + +常用命令: + +```bash +node scripts/deploy.mjs init +node scripts/deploy.mjs update --target auto --migrate auto +``` + +或使用 package scripts: + +```bash +bun run deploy:init +bun run deploy:update -- --target auto --migrate auto +``` + +说明: + +- 该脚本仅执行本地流程(构建 + 本地迁移),不会触发远程部署 +- `init` 会强制执行本地迁移 +- `update` 默认按变更范围决定是否迁移(无法检测时会默认迁移) +- `bun run deploy` 会进入交互选择(init/update、部署目标、迁移策略) +- 若 `.env` 不存在,会自动从 `.env.example` 生成(或创建空文件) ## API 接口 diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index bb43d8b..1e04f0e 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -126,7 +126,7 @@ const App = () => { return pathToTab[normalized] ?? "dashboard"; }); const [loading, setLoading] = useState(false); - const [notice, setNotice] = useState(null); + const [notices, setNotices] = useState([]); const [data, setData] = useState(initialData); const [settingsForm, setSettingsForm] = useState(initialSettingsForm); @@ -159,6 +159,7 @@ const App = () => { const [checkinLastRun, setCheckinLastRun] = useState(null); const [, setPendingActions] = useState>(() => new Set()); const pendingActionsRef = useRef>(new Set()); + const noticeTimersRef = useRef>(new Map()); const [confirmState, setConfirmState] = useState(null); const [confirmPending, setConfirmPending] = useState(false); @@ -173,25 +174,52 @@ const App = () => { const pushNotice = useCallback( (tone: NoticeTone, message: string, durationMs?: number) => { - setNotice({ tone, message, id: Date.now(), durationMs }); + setNotices((prev) => [ + ...prev, + { tone, message, id: Date.now() + Math.random(), durationMs }, + ]); }, [], ); - const dismissNotice = useCallback(() => { - setNotice(null); + const dismissNotice = useCallback((id?: number) => { + setNotices((prev) => { + if (id === undefined) { + return []; + } + return prev.filter((item) => item.id !== id); + }); }, []); useEffect(() => { - if (!notice) { - return; + const timers = noticeTimersRef.current; + const activeIds = new Set(notices.map((item) => item.id)); + for (const [id, timer] of timers) { + if (!activeIds.has(id)) { + window.clearTimeout(timer); + timers.delete(id); + } } - const durationMs = notice.durationMs ?? 4500; - const timer = window.setTimeout(() => { - setNotice(null); - }, durationMs); - return () => window.clearTimeout(timer); - }, [notice]); + for (const notice of notices) { + if (timers.has(notice.id)) { + continue; + } + const durationMs = notice.durationMs ?? 4500; + const timer = window.setTimeout(() => { + dismissNotice(notice.id); + }, durationMs); + timers.set(notice.id, timer); + } + }, [dismissNotice, notices]); + + useEffect(() => { + return () => { + for (const timer of noticeTimersRef.current.values()) { + window.clearTimeout(timer); + } + noticeTimersRef.current.clear(); + }; + }, []); const startAction = useCallback((key: string) => { if (pendingActionsRef.current.has(key)) { @@ -1326,6 +1354,7 @@ const App = () => { () => tabs.find((tab) => tab.id === activeTab)?.label ?? "管理台", [activeTab], ); + const loginNotice = notices[notices.length - 1] ?? null; const renderContent = () => { if (loading) { @@ -1440,6 +1469,7 @@ const App = () => { { activeTab={activeTab} activeLabel={activeLabel} token={token} - notice={notice} + notices={notices} onDismissNotice={dismissNotice} onTabChange={handleTabChange} onLogout={handleLogout} @@ -1468,7 +1498,7 @@ const App = () => { ) : ( )} @@ -1476,7 +1506,7 @@ const App = () => {
{children} - {notice && ( - -
-
-
- - {noticeToneLabel[notice.tone]} - -
- {notice.message} + {notices.length > 0 && ( + + {notices.map((notice) => ( +
+
+
+ + {noticeToneLabel[notice.tone]} + +
+ {notice.message} +
+
- +
-
+ ))} )} +
+ +
+

运行时配置(只读)

+

+ 需要调整请在 Cloudflare Dashboard 或部署环境中修改以下环境变量, + 此处仅展示当前生效值。 +

+
+ {runtimeItems.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+
+ 环境变量: PROXY_STREAM_USAGE_MODE, + PROXY_STREAM_USAGE_MAX_BYTES, PROXY_STREAM_USAGE_MAX_PARSERS, + PROXY_USAGE_QUEUE_ENABLED +
+
-
-
- - { - const target = event.currentTarget as HTMLInputElement | null; - onFormChange({ - log_retention_days: target?.value ?? "", - }); - }} - /> -
-
- - { - const target = event.currentTarget as HTMLInputElement | null; - onFormChange({ - session_ttl_hours: target?.value ?? "", - }); - }} - /> -
-
- - { - const target = event.currentTarget as HTMLInputElement | null; - onFormChange({ - checkin_schedule_time: target?.value ?? "", - }); - }} - /> -
-
- - { - const target = event.currentTarget as HTMLInputElement | null; - onFormChange({ - model_failure_cooldown_minutes: target?.value ?? "", - }); - }} - /> -

- 同一模型失败后在该时间内跳过对应渠道。 -

-
-
- - { - const target = event.currentTarget as HTMLInputElement | null; - onFormChange({ - admin_password: target?.value ?? "", - }); - }} - /> -

- 密码状态:{adminPasswordSet ? "已设置" : "未设置"} -

-
-
- -
-
-
-); + ); +}; diff --git a/apps/ui/src/features/SitesView.tsx b/apps/ui/src/features/SitesView.tsx index a165d0e..6ed8256 100644 --- a/apps/ui/src/features/SitesView.tsx +++ b/apps/ui/src/features/SitesView.tsx @@ -579,7 +579,7 @@ export const SitesView = ({