9 Commits

54 changed files with 1106 additions and 29243 deletions
-1
View File
@@ -1 +0,0 @@
NODE_ENV=test
+1 -1
View File
@@ -34,6 +34,7 @@ docker/*.tar
.tours/ .tours/
.trellis/ .trellis/
.codegraph/ .codegraph/
.hallmark/
bun.lock bun.lock
# Embedded desktop subproject (BoosAPI Desktop) — generated artifacts only. # Embedded desktop subproject (BoosAPI Desktop) — generated artifacts only.
@@ -47,4 +48,3 @@ desktop/website/node_modules/
# Root uses pnpm (pnpm-lock.yaml); ignore stray npm lockfile # Root uses pnpm (pnpm-lock.yaml); ignore stray npm lockfile
package-lock.json package-lock.json
package-lock.json
-10
View File
@@ -1,10 +0,0 @@
[
{
"date": "2026-07-11",
"scope": "app",
"macrostructure": "Workbench",
"theme": "custom-boosapi-teal",
"enrichment": "none",
"brief": "BoosAPI operations console design system and dashboard rollout"
}
]
+67 -89
View File
@@ -1,103 +1,81 @@
# BoosAPI Engineering Rules # BoosAPI Engineering Rules
> **AI开发**: 使用 **gpt-5.6**(`api.boos.lat/v1`)进行开发工作。 ## Project
> 本项目为纯 Rust 项目,无 Node.js 运行时依赖。
## 项目结构 BoosAPI is a Rust API gateway. The production server is built with Cargo and
serves the React/Vite frontend from `dist/web/` and the standalone website
from `dist/website/`.
- Backend entry point: `src/main.rs`
- Library entry point: `src/lib.rs`
- HTTP routes: `src/routes/`
- Proxy core: `src/proxy/`
- Background services: `src/services/`
- Database code: `src/db/`
- Protocol transformers: `src/transformers/`
- SQL migrations: `migrations/`, `migrations_pg/`, and `migrations_mysql/`
- Desktop workspace: `desktop/`
Use Rust and the existing project patterns. Do not introduce a Node.js runtime
dependency into the production server.
## Development
```bash
cargo run --release
cargo metadata --no-deps --format-version 1
cargo test
``` ```
boosapi-rs/ ← 根目录(已迁移至此)
├── src/ The frontend uses Vite during development and is built into `dist/web/` for
│ ├── main.rs ← 入口 the Rust server. Do not commit generated build output, local databases, logs,
│ ├── lib.rs or dependency installation directories.
│ ├── config.rs
│ ├── error.rs ## Data And Migrations
│ ├── routes/
│ │ ├── mod.rs - Keep SQLite, PostgreSQL, and MySQL migration history aligned when a schema
│ │ ├── comfyui_agent.rs ← ComfyUI Agent SSE 聊天 change affects more than one database.
│ │ ├── comfyui/ ← ComfyUI 代理路由 - Use the existing database helpers and query style.
│ │ ├── proxy/ ← OpenAI 兼容代理 - Do not commit local database files or credentials.
│ │ ├── auth/ ← 认证 - `vendor/sqlx-*` is currently intentional: `Cargo.toml` patches SQLx core,
│ │ └── ... MySQL, and SQLite crates to these local sources. Do not remove them without
│ ├── proxy/ ← 代理核心(Conductor/Token Router) first removing the patch and validating the complete Cargo build.
│ ├── services/ ← 后台服务
│ ├── db/ ← 数据库 ## Picture Agent
│ └── transformers/ ← 协议转换
├── dist/web/ ← 前端构建产物(由 Rust 静态 serve) The Picture Agent routes are under `src/routes/picture_agent.rs`. Template data
├── migrations/ ← SQLite 迁移 is fetched from the configured remote gallery source at request time; do not
├── config.toml ← 运行配置 add the Linux Do template JSON as a bundled local asset.
├── Dockerfile
└── Cargo.toml Picture generation jobs must continue to use the existing authentication,
background task, workspace asset, and usage tracking services.
## ComfyUI Agent ## ComfyUI Agent
ComfyUI Agent 默认走 **本地模式**:复用本服务自身的 `/v1/*` 路由,bearer 用启动时引导出来的内置 downstream key(`name='comfyui-agent' AND user_id IS NULL`,密钥格式 `sk-comfyui-{uuid_v4_hex}`,同时镜像写入 `settings.builtin_key.comfyui_agent.secret`)。 The ComfyUI Agent defaults to local mode and reuses this service's `/v1/*`
routes with the built-in `comfyui-agent` downstream key. When
`BOOSAPI_UPSTREAM_BASE` is set, it uses fallback remote mode. Preserve the
built-in key protections, idempotent startup provisioning, and SSE marker
handling for `CHARACTER`, `SCENE`, `TTS`, and `WORKFLOW`.
行为契约: ## Frontend Sessions
- **Local(默认)**:`upstream_base` = `http://{server.host}/v1`(host 是 `0.0.0.0` / `::` 时改写为 `127.0.0.1`),bearer = 内置 key;`/api/comfyui-agent/status` 回 `mode: "local"` + `keyName: "comfyui-agent"`
- **FallbackRemote**:当 `BOOSAPI_UPSTREAM_BASE`(trim 后非空)被设置时切换到外部上游;`BOOSAPI_UPSTREAM_KEY` 缺省时仍用内置 key;status 回 `mode: "fallback_remote"` + `keyName: "external"`
- 内置 key 受保护:admin 列表对其 key 字段打码(`••••<后4位>`),删除返 403,禁用(`enabled=false`)返 403;`builtin_key.*` settings 不在 list 中暴露,PUT 返 403
- 启动期 `ensure_comfyui_agent_key` 是幂等的:缺密钥就生成、`enabled=0` 自动恢复并 `tracing::warn!`、settings 与 downstream_api_keys 双向同步
支持的结构化标记(输出由 LLM 生成、由 agent SSE 解析下发): Use `src/web/authSession.ts` for new session work:
- 【CHARACTER】 → 自动调用 gpt-image-2 生成四视图
- 【SCENE】 → 自动生成场景图
- 【TTS】 → 自动调用 TTS API
- 【WORKFLOW】 → 输出 ComfyUI 工作流 JSON
## 即梦 Provider(图片 + 视频) - `commitSession`
- `clearSession`
- `getSessionToken`
火山引擎「智能视觉 / CV」异步生成 provider,图片与视频共用一套「提交 → 轮询 → 转存 → 计费」状态机,靠 `capability → req_key` 映射区分能力。 Keep admin and user audiences distinct. OAuth slots are placeholders until a
feature explicitly wires their behavior. Centralize login error text in
`src/web/loginError.ts`.
- **能力映射**(`services/jimeng/types.rs` 单一事实源): ## Editing And Verification
- 图片 `JimengCapability::Image` → `req_key = jimeng_t2i_v40`,轮询 ~2s / 超时 ~120s,产物 `image_urls[]`(或 `binary_data_base64[]`),24h 有效
- 视频 `JimengCapability::Video` → `req_key = jimeng_ti2v_v30_pro`,轮询 ~8s / 超时 ~600s,产物 `video_url`,**仅 1h 有效**
- 新增能力(视频3.0 / OmniHuman 等)只加枚举分支,submit/poll/转存逻辑不动
- **签名只当签名器**:用 `volc-visual-sdk` 的 `VisualClient::build_signed_request`(纯签名 V4),HTTP 走 metapi 自己的 async `state.http`,绝不调 SDK 的 blocking `request()`(依赖以 `default-features = false` 引入,不拉 `reqwest::blocking`)。Action 显式传 `CVSync2AsyncSubmitTask` / `CVSync2AsyncGetResult`(Version `2022-08-31`,走 query),业务参数走 JSON body。
- **解析铁律**:先判外层 `code == 10000` 再读 `data.status`(错误响应 `data:null`,顺序反了会 panic);错误码可重试性见 `types::classify`(输入审核类不可重试,QPS/输出审核类可重试,内部错误码视频可重试、图片不重试)。
- **任务模型零迁移**:复用 `background_tasks`(`task_type = jimeng-image-generation` / `jimeng-video-generation`,本地 id `jmi_*` / `jmv_*`),火山 `task_id` 存进 `result_json.volcTaskId`,进度 1=已提交 / 2=生成中 / 3=转存中 / 4=完成。
- **产物转存**:轮询到 `done` 立即 `workspace::record_remote_asset`(视频 1h 过期硬约束,绝不透传火山临时 URL),对外只发本地 `public_id` + `/api/user/workspace/assets/{id}/file`。
- **计费**:复用 `UsageTracker` 的 `record_comfy_task_success/failure`(`model = req_key`,单价来自 `config.jimeng.{image_cost,video_cost}`,首版默认 0)。
- **路由**(鉴权照 `picture_agent` 的 bearer / JWT 解析):
- `POST /api/jimeng/image/jobs`、`POST /api/jimeng/video/jobs` → 立即返回本地 task_id,后台 `tokio::spawn` 轮询
- `GET /api/jimeng/jobs`、`GET /api/jimeng/jobs/{id}`、`POST /api/jimeng/jobs/{id}/cancel`、`GET /api/jimeng/status`
- **配置**:`[jimeng]` 段,AK/SK 走 env(`BOOSAPI_JIMENG__ACCESS_KEY` / `__SECRET_KEY` / 可选 `__SESSION_TOKEN`),不硬编码;`enabled=false` 时路由返回 503「能力未启用」。
- **首版范围**:图生视频只支持 `image_urls`(公网图 URL),前端上传 → 转存留二期;`frames ∈ {121(5s), 241(10s)}`,`aspect_ratio` 白名单。
### 即梦在「站点管理 / 连接管理」里的特殊处理(platform=`jimeng`) - Keep changes scoped and preserve unrelated user changes.
- Prefer `rg` for searches and structured parsers for JSON/TOML/SQL.
即梦作为一个 `platform = "jimeng"` 录进现有 `sites` + `accounts` 体系(复用 CRUD / 列表 / 状态 UI),但因为它**不是 OpenAI 兼容协议**(火山 V4 HMAC 签名、异步两步生成、无 `/models` 端点),所有 OpenAI 专属路径都为它开了**干净的分支**,绝不污染其它平台: - Use `apply_patch` for manual edits.
- Add focused tests for changed behavior.
- **平台判定**:`routes::admin::accounts::is_jimeng_platform(&site.platform)`(大小写不敏感)是唯一事实源;站点 `platform` 字段为自由字符串,无白名单,前端下拉新增 `jimeng` 选项即可。 - Run the narrowest relevant test or Cargo check before committing.
- **AK/SK 存法**:AK 落 `accounts.access_token`;SK + region/host/session_token 收进 `extra_config.jimeng.{secretKey,region,host,sessionToken}` JSON。`credentialMode` 强制 `apikey`(只在「API Key 管理」分段出现,永不进 session 分段)。**SK / sessionToken 是机密**:列表 / get / update 响应一律用 `mask_account_extra_config` 替换为 `••••`;带掩码的 `extra_config` 回写会被 `update_account` 拒绝(留空即保留原值)。 - Never commit secrets, generated artifacts, local environment files, or
- **验证(签名探测)**:`verify_token` 检测到 jimeng 平台时走 `verify_jimeng_token` → `JimengProvider::from_credentials(ak, sk, region, host, session).verify()`,对 GetResult 发一个哨兵 `task_id` 的最小签名请求。火山网关在业务层之前校验 V4 签名:签名通过 → 业务层报「任务不存在」(即 `Authenticated`);签名失败 → 网关返回鉴权类 `Code`(`classify_verify_response` 按 `is_auth_error_code` 判定 `AuthFailed`)。**绝不调 `/models`**,绝不触发生成。 temporary deployment output.
- **不进路由表**:`create_account` 检测 jimeng 后走 `create_jimeng_account`,单条插入、**不** `spawn_route_rebuild`;前端 `handleTokenAdd` 对 jimeng 也跳过 `checkModels` / `rebuildRoutes`。
- **站点 probe 短路**:`build_probe_plan`(probe-now / probe-stream 共用入口)对 jimeng 直接 `BadRequest`,提示改用连接管理的「验证」做签名连通性检查(逐模型 chat 探测对它无意义)。
- **运行健康来源**:jimeng 不走 OpenAI 代理通道(独立 `/api/jimeng/*`),`proxy_logs` 无记录,否则永远 `unknown`。改由 `derive_jimeng_runtime_health` 从最近一条 `background_tasks`(`jimeng-image/video-generation`)派生平台级健康(succeeded→healthy / failed→unhealthy / running·pending→进行中 / 无任务→「凭证验证通过后即可使用」),`account_response_value_with_jimeng` 在站点为 jimeng 时用它覆盖 proxy-log 派生值;其它平台传入的 jimeng 健康一律忽略(回归零影响,有断言测试守护)。
- **前端表单**:`Sites.tsx` 选 jimeng 时预填 URL `https://visual.volcengineapi.com`,隐藏「API 请求地址池」与「刷新后自动探测」;`Accounts.tsx` 在 API Key 分段对 jimeng 改成 AK + SK(必填)+ region/host/session(选填),点「验证 AK/SK 签名」做签名探测,校验通过才能添加。
## 运行
```bash
# 开发
cargo run --release
# 环境变量
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
BOOSAPI_UPSTREAM_KEY=sk-boos4721
BOOSAPI_AUTH__AUTH_TOKEN=change-me-admin-token
```
## 前端
前端为 React SPA,使用 Vite 构建,产物在 `dist/web/`:
- 开发时用 Vite dev server(需 Node.js)
- 生产时由 Rust 后端直接 serve 静态文件
### 登录与会话
- 登录页位于 `src/web/pages/auth/`:`LoginShell` 提供共享外壳,`AdminLoginPage`(token / 账密双模式)与 `UserLoginPage`(账密)分别承担两类受众。OAuth 入口用 `OAuthSlot` 渲染占位区域,**实际行为由后续 change 接入**——当前不要在这里挂任何 onClick / 跳转。
- 会话状态统一走 `src/web/authSession.ts` 的 `commitSession / clearSession / getSessionToken`,受众通过 `'admin' | 'user'` 区分;旧的 `persistAuthSession / clearAuthSession / getAuthToken / hasValidAuthSession`(及 `*UserSession` 同族)保留为 `@deprecated` 薄壳,新代码不要再引用。
- 登录错误文案统一走 `src/web/loginError.ts` 的 `resolveLoginErrorMessage(status, raw)`;新增登录错误请加在该处而不是各自分支里手写。
-89
View File
@@ -1,89 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
BoosAPI is an AI API gateway: an OpenAI/Anthropic/Gemini-compatible proxy with a token-routing/failover engine, a management console (React SPA), embedded ComfyUI workflow surfaces, billing, and payments. Backend is **pure Rust** (axum + sqlx + tokio); there is no Node.js runtime dependency at serving time — Node is only used to build frontend assets.
Project-internal docs are largely in Chinese (`AGENTS.md`, `README.md`); match that when editing them.
## Build & run
The wrapper script `./build-and-run.sh` is the normal entry point (builds frontend + backend, then runs). Key flags:
```bash
./build-and-run.sh --check # cargo check only — fastest verification, no frontend, no run
./build-and-run.sh # dev profile, reuse frontend cache, compile + start
./build-and-run.sh --backend-only # recompile/restart backend, skip Vite
./build-and-run.sh --fast-release # near-prod performance, faster than --release
./build-and-run.sh --production # build + embed frontend/Workflow UI into binary -> dist/production/
```
Cargo profiles: `dev` (default), `release` (LTO, slow), `fast-release` (opt-level 2, no LTO — use for staging verification). Direct cargo also works: `cargo run --release`, `cargo check`.
Config comes from `config.toml` plus env overrides via figment. Env vars use the `BOOSAPI_` prefix with `__` as the nesting separator, e.g. `BOOSAPI_SERVER__PORT=4000`, `BOOSAPI_AUTH__AUTH_TOKEN=...`, `BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=...`. Prefer env vars for all secrets.
## Tests
```bash
cargo test # Rust tests (rstest, wiremock, axum-test, tracing-test)
cargo test <name> # single Rust test by name substring
npm test # web tests: vitest run --root src/web
npm run test:watch # vitest watch
npx vitest run src/web/api.test.ts # single web test file
npm run typecheck # tsc for src/web (app + tests)
```
Rust tests live in `#[cfg(test)]` modules alongside source. Web tests are `src/web/**/*.test.ts(x)`.
## Architecture
### Proxy core (`src/proxy/`) — the heart of the system
Request flow for `/v1/*`: **downstream key auth → token route resolution → channel selection → upstream call (with failover) → protocol transform → response**.
- `token_router/` — `TokenRouter` resolves a requested model name to candidate upstream channels using DB-backed route rows + regex patterns, with a short (2s) in-process cache. Call `invalidate_route_cache()` after mutating routes/channels. Failure cooldown per channel is configurable.
- `conductor/` — `ProxyConductor::execute` drives attempt/failover loop (max 5 attempts). It is decoupled from the DB via injected closures (`select_channel`, `select_next_channel`, `record_success`, `record_failure`, optional `refresh_auth`). `retry_policy.rs` classifies failures into actions: failover, retry-same-channel, refresh-auth, or terminal.
- `executors/`, `providers/`, `surfaces/`, `capabilities/`, `cli_profiles/` — execution backends and provider/capability metadata.
### Protocol transformers (`src/transformers/`)
`openai/`, `anthropic/`, `gemini/` convert between client-facing protocol and upstream protocol around a `canonical/` intermediate form. When adding model/protocol support, work through the canonical layer rather than point-to-point conversions.
### Routes (`src/routes/`)
`mod.rs` assembles the axum router by `.merge()`-ing each module's `routes()` and serves the SPA as a fallback (embedded via `include_dir` in production, or from `dist/web` on disk). `admin/` is the management API; `proxy/` is the OpenAI-compatible entry; `comfyui/`, `comfyui_agent.rs`, `workflow_ui.rs`, `digital_human.rs`, `money_printer.rs`, `picture_agent.rs` are the ComfyUI/creative surfaces.
### Services (`src/services/`)
Background and cross-cutting logic: `billing/`, `payment.rs`, `cron/`, `notification/`, `runtime_auth.rs`, `builtin_keys.rs`, `comfyui_process.rs`, `route_rebuild.rs`. Shared `AppState` (in `src/lib.rs`) is cloned into handlers and holds config, DB pool, HTTP client, and these service handles.
### Database
**SQLite is the only runtime path** — `AppState.db` is `Arc<SqlitePool>`, and all runtime queries target SQLite. The Postgres dependency and `boosapi migrate-sqlite-to-postgres` subcommand exist only to copy/migrate schema+data into Postgres; full Postgres runtime query compatibility is not done. Do not assume queries run against Postgres. Migrations live in `migrations/` (SQLite); `db/schema_compat.rs` applies runtime compatibility fixups after migrations.
## ComfyUI Agent (local-mode contract)
The ComfyUI Agent defaults to **local mode**: it calls this service's own `/v1/*` routes using a bootstrapped built-in downstream key (`name='comfyui-agent' AND user_id IS NULL`, format `sk-comfyui-{uuid_hex}`, mirrored into `settings.builtin_key.comfyui_agent.secret`).
- Local (default): `upstream_base = http://{host}/v1` (host `0.0.0.0`/`::` rewritten to `127.0.0.1`); `/api/comfyui-agent/status` returns `mode: "local"`.
- Fallback-remote: set `BOOSAPI_UPSTREAM_BASE` (non-empty) to use an external upstream; `BOOSAPI_UPSTREAM_KEY` falls back to the built-in key if unset.
- The built-in key is **protected**: admin list masks it (`••••<last4>`), delete/disable return 403, and `builtin_key.*` settings are hidden from list / 403 on PUT. Startup `ensure_comfyui_agent_key` is idempotent (regenerates if missing, re-enables if disabled, keeps settings ↔ downstream_api_keys in sync).
Workflow backend modes (`[comfyui] backend`): `cloud` (Comfy Cloud, default), `runninghub`, `external` (your own ComfyUI), `boosapi` (built-in Rust workflow/API node executor), `docker` (legacy). Workflow UI has separate `cloud` and `external/runninghub` frontend asset bundles — install with `npm run workflow-ui:install:cloud` / `:external`.
## Frontend (two separate apps)
- `src/web/` — management console, React 18 + Vite, built to `dist/web/` and served by the Rust backend. `npm run dev` for HMR, `npm run build:web` to build.
- `website/` — standalone marketing site, **Next.js 16 App Router + React 19**, its own package. Use `npm run website:dev` / `website:build` / `website:typecheck` (these proxy to `npm --prefix website`).
### Auth/session conventions (src/web)
- Session state goes through `authSession.ts`: `commitSession / clearSession / getSessionToken`, bucketed by audience `'admin' | 'user'`. The old `persistAuthSession / clearAuthSession / getAuthToken / hasValidAuthSession` family is `@deprecated` — do not use in new code.
- Login pages are split under `pages/auth/`: `LoginShell` (shared shell) + `AdminLoginPage` (token / account modes) + `UserLoginPage`. OAuth entries render via `OAuthSlot` as placeholders only — do not wire onClick/navigation there.
- Login error text is normalized in `loginError.ts` via `resolveLoginErrorMessage(status, raw)` — add new login errors there, not inline.
## Shared TS/Rust contracts (`src/shared/`)
This directory holds logic mirrored between Rust (`.rs`) and TypeScript (`.ts`/`.js`) — e.g. `tokenRouteContract`, `tokenRoutePatterns`, `modelBrand`, `platformIdentity`, `proxyLogMeta`, `siteInitializationPresets`. When changing a routing/identity contract, keep the Rust and TS sides in sync.
Generated
+1
View File
@@ -527,6 +527,7 @@ dependencies = [
"jsonwebtoken", "jsonwebtoken",
"lettre", "lettre",
"libc", "libc",
"lopdf",
"md-5", "md-5",
"mime", "mime",
"mime_guess", "mime_guess",
+1
View File
@@ -96,6 +96,7 @@ include_dir = "0.7"
mime_guess = "2" mime_guess = "2"
office_oxide = "=0.1.5" office_oxide = "=0.1.5"
pdf-extract = "=0.12.0" pdf-extract = "=0.12.0"
lopdf = { version = "0.42", default-features = false }
# Embedded AI agent engine (MIT, vendored as a path dependency). Provides the # Embedded AI agent engine (MIT, vendored as a path dependency). Provides the
# agentic loop, built-in tools, permission callback and cost tracking that the # agentic loop, built-in tools, permission callback and cost tracking that the
+23 -13
View File
@@ -27,8 +27,8 @@
## 2. 开发环境 (Development) ## 2. 开发环境 (Development)
### 前提条件 ### 前提条件
- **Rust**: 1.85+ (推荐使用 rustup) - **Rust**: 1.88+ (推荐使用 rustup)
- **Node.js**: 20+ (仅用于前端构建) - **Bun**: 1.2+ (仅用于前端构建,生产运行不需要 Bun)
- **SQLite**: 系统自带或手动安装 - **SQLite**: 系统自带或手动安装
### 步骤 ### 步骤
@@ -39,24 +39,26 @@
``` ```
2. **配置环境**: 2. **配置环境**:
复制 `config.toml.example` 并根据需要修改: 编辑仓库中的 `config.toml`,或直接设置环境变量:
```bash ```bash
cp config.toml.example config.toml export BOOSAPI_SERVER__HOST=127.0.0.1
export BOOSAPI_SERVER__PORT=4000
export BOOSAPI_AUTH__AUTH_TOKEN=change-me-admin-token
``` ```
或者直接设置环境变量。 或者直接设置环境变量。
3. **运行前端 (热更新模式)**: 3. **运行前端 (热更新模式)**:
```bash ```bash
npm install bun install --frozen-lockfile
# 启动 vite 开发服务器 # 启动 Vite 开发服务器
npm run dev:web bun run dev
``` ```
默认访问 `http://localhost:5173`。Vite 会将 `/api` 请求代理到后端 `localhost:4000`。 默认访问 `http://localhost:5173`。Vite 会将 `/api` 请求代理到后端 `localhost:4000`。
4. **运行后端**: 4. **运行后端**:
```bash ```bash
# 使用默认配置运行 # 使用默认配置运行
cargo run cargo run --release
# 推荐:使用 cargo-watch 自动重载 # 推荐:使用 cargo-watch 自动重载
# cargo install cargo-watch # cargo install cargo-watch
# cargo watch -x run # cargo watch -x run
@@ -120,10 +122,7 @@ config.toml
1. **构建前端与后端**: 1. **构建前端与后端**:
```bash ```bash
# 构建前端 ./build-and-run.sh --production
npm run build
# 构建后端
cargo build --release
``` ```
2. **安装**: 2. **安装**:
@@ -232,10 +231,21 @@ backend = "boosapi"
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。 - `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。 - `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。 - 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;`build-and-run.sh` 会按需安装与构建。
--- ---
### 图片助手模板
`/api/picture-agent/templates` 在请求时从远程 Linux Do 图库读取模板 JSON,不在本地
捆绑数据文件;请求设置 30 秒超时和 16 MB 响应上限。
### SQLx vendor
`vendor/sqlx-core`、`vendor/sqlx-mysql` 和 `vendor/sqlx-sqlite` 由根目录
`Cargo.toml` 的 `[patch.crates-io]` 显式引用,当前不能作为冗余文件删除。移除前需要
先删除这些 patch,并完成完整 Cargo 构建和数据库迁移验证。
## 5. 运维操作 (Ops) ## 5. 运维操作 (Ops)
### 检查配置 ### 检查配置
+35 -25
View File
@@ -15,7 +15,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat"> <img alt="Rust" src="https://img.shields.io/badge/Rust-1.88%2B-000000?logo=rust&style=flat">
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat"> <img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat"> <img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat"> <img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
@@ -57,7 +57,7 @@
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 | | 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 | | 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
## 2026-06 更新重点 ## 2026-07 更新重点
- **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。 - **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。
- **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。 - **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。
@@ -66,8 +66,11 @@
- **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。 - **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。
- **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。 - **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。
- **Comfy Checkout 充值**:用户先在系统内创建充值记录,再跳转到配置的 Comfy Checkout 链接付款;充值记录会保留用于后续对账。 - **Comfy Checkout 充值**:用户先在系统内创建充值记录,再跳转到配置的 Comfy Checkout 链接付款;充值记录会保留用于后续对账。
- **Workflow UI 更新**:`npm run workflow-ui:install:cloud` 会安装当前 Comfy Cloud 前端资产并写入构建元数据,Rust 服务会对 JS/CSS 走 no-cache,避免继续显示旧版本。 - **Workflow UI 更新**:`bun run workflow-ui:install:cloud` 会安装当前 Comfy Cloud 前端资产并写入构建元数据,Rust 服务会对 JS/CSS 走 no-cache,避免继续显示旧版本。
- **本地化时间**:前端展示时间优先使用浏览器/UA 时区,后端无时区时间按 UTC 解析后再转成本地显示。 - **本地化时间**:前端展示时间优先使用浏览器/UA 时区,后端无时区时间按 UTC 解析后再转成本地显示。
- **图片助手模板**:模板接口按请求读取远程图库数据,不在仓库中捆绑大体积 JSON。
- **Agent 文档能力**:可读取常见 Office、PDF 和文本附件,并生成可下载的 DOCX、XLSX、PPTX、PDF、Markdown、TXT、CSV、JSON 文件。
- **部署方式统一**:前端统一使用 Bun,生产构建统一使用 `./build-and-run.sh --production`。
## ✨ 核心功能 ## ✨ 核心功能
@@ -85,6 +88,13 @@
- **默认走本地模式**:复用本服务自身的 `/v1/*` 路由 + 启动时引导出来的内置 downstream key(`comfyui-agent`),开箱即用,无需额外配置 - **默认走本地模式**:复用本服务自身的 `/v1/*` 路由 + 启动时引导出来的内置 downstream key(`comfyui-agent`),开箱即用,无需额外配置
- **回退远端**:设置 `BOOSAPI_UPSTREAM_BASE`(可选 `BOOSAPI_UPSTREAM_KEY`)即切换为外部上游;`/api/comfyui-agent/status` 的 `mode` 字段会反映当前模式 - **回退远端**:设置 `BOOSAPI_UPSTREAM_BASE`(可选 `BOOSAPI_UPSTREAM_KEY`)即切换为外部上游;`/api/comfyui-agent/status` 的 `mode` 字段会反映当前模式
### 🤖 Agent Workbench
- 支持上传并读取 PDF、DOC/DOCX、XLS/XLSX、PPT/PPTX、TXT、Markdown、CSV 和 JSON 附件
- 支持生成 DOCX、XLSX、PPTX、PDF、Markdown、TXT、CSV 和 JSON,完成后在对话中提供鉴权下载
- 生成文件归档到当前用户的工作空间,并随会话恢复;单个附件或输出文件上限为 64 MB
- 工具写入遵循 Agent 权限模式,默认模式下生成文件需要用户确认
### 🌐 统一代理网关 ### 🌐 统一代理网关
- 兼容 **OpenAI** 与 **Claude** 下游格式 - 兼容 **OpenAI** 与 **Claude** 下游格式
@@ -261,7 +271,7 @@ curl http://127.0.0.1:4000/v1/videos \
返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。 返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。 > Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `bun run workflow-ui:install:cloud`、`bun run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。
## 🚀 快速开始 ## 🚀 快速开始
@@ -331,7 +341,8 @@ docker compose up -d
├── config.toml # 默认配置文件 ├── config.toml # 默认配置文件
├── Dockerfile # 多阶段构建 ├── Dockerfile # 多阶段构建
├── docker/ ├── docker/
│ └── docker-compose.yml │ ├── docker-compose.databases.yml
│ └── docker-compose.override.yml
├── src/ ├── src/
│ ├── main.rs # 入口 │ ├── main.rs # 入口
│ ├── lib.rs # 库入口 │ ├── lib.rs # 库入口
@@ -359,6 +370,8 @@ docker compose up -d
│ ├── billing/ # 计费逻辑 │ ├── billing/ # 计费逻辑
│ └── notification/ # 通知推送 │ └── notification/ # 通知推送
├── migrations/ # SQLite 迁移 ├── migrations/ # SQLite 迁移
├── migrations_pg/ # PostgreSQL 迁移
├── migrations_mysql/ # MySQL/MariaDB 迁移
├── src/web/ # 管理台前端源码(React + Vite) ├── src/web/ # 管理台前端源码(React + Vite)
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶) │ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage) │ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
@@ -489,7 +502,7 @@ socket 不需要的 PAM/LDAP,并剥离符号;可选体积模式额外使用
| 样式 | Tailwind CSS v4 | | 样式 | Tailwind CSS v4 |
| 数据可视化 | VChart | | 数据可视化 | VChart |
| 容器化 | Docker + Docker Compose + K3s | | 容器化 | Docker + Docker Compose + K3s |
| 构建工具 | Cargo(后端)/ npm(前端静态构建) | | 构建工具 | Cargo(后端)/ Bun(前端静态构建) |
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。 > **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
@@ -541,21 +554,21 @@ export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
```bash ```bash
# 安装依赖(首次) # 安装依赖(首次)
npm install bun install --frozen-lockfile
# 开发模式(热更新,需要 Node.js) # 开发模式(热更新,需要 Bun)
cd src/web && npx vite --host 0.0.0.0 bun run dev --host 0.0.0.0
# 类型检查与构建生产版本 # 类型检查与构建生产版本
npm run typecheck:web bun run typecheck:web
npm run build:web bun run build:web
# 输出到 dist/web/,Rust 后端自动 serve # 输出到 dist/web/,Rust 后端自动 serve
``` ```
生图助手的 tldraw 无限画布在 HTTPS 生产域名需要有效许可证。许可证是域名绑定的客户端构建变量,构建时注入即可: 生图助手的 tldraw 无限画布在 HTTPS 生产域名需要有效许可证。许可证是域名绑定的客户端构建变量,构建时注入即可:
```bash ```bash
VITE_TLDRAW_LICENSE_KEY='tldraw-...' npm run build:web VITE_TLDRAW_LICENSE_KEY='tldraw-...' bun run build:web
``` ```
`build-and-run.sh` 会把该变量的哈希纳入前端构建指纹;许可证变化时会自动重新构建静态资源。本地 HTTP / loopback 开发环境不要求许可证。当前仓库仅用于绑定 `0.0.0.0` 或 `::` 的本机/局域网测试,因此 `vite.config.ts` 固定将 `process.env.NODE_ENV` 定义为 `development`。公开部署时必须改回 `production` 并使用 `VITE_TLDRAW_LICENSE_KEY`。 `build-and-run.sh` 会把该变量的哈希纳入前端构建指纹;许可证变化时会自动重新构建静态资源。本地 HTTP / loopback 开发环境不要求许可证。当前仓库仅用于绑定 `0.0.0.0` 或 `::` 的本机/局域网测试,因此 `vite.config.ts` 固定将 `process.env.NODE_ENV` 定义为 `development`。公开部署时必须改回 `production` 并使用 `VITE_TLDRAW_LICENSE_KEY`。
@@ -564,26 +577,23 @@ Workflow UI 前端资产单独更新:
```bash ```bash
# Comfy Cloud 前端 # Comfy Cloud 前端
npm run workflow-ui:install:cloud bun run workflow-ui:install:cloud
# External / RunningHub 前端 # External / RunningHub 前端
npm run workflow-ui:install:external bun run workflow-ui:install:external
``` ```
可选使用 Bun 加速主前端构建: 构建脚本默认使用 Bun:
```bash ```bash
# 一次性使用 Bun 构建主前端
./build-and-run.sh --bun ./build-and-run.sh --bun
# 显式指定 Bun
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run
BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
``` ```
> Workflow UI 子模块仍按官方 `pnpm-lock.yaml` 使用 pnpm 构建,避免 ComfyUI 前端依赖兼容问题。 > Workflow UI 子模块也统一使用 Bun 构建,避免本地开发环境出现多套包管理器。
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。 > 生产环境不需要 Bun 进程。`bun run build:web` 后,Rust 后端直接 serve 静态文件。
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。 > 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
### 独立官网(Next.js) ### 独立官网(Next.js)
@@ -592,14 +602,14 @@ BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
```bash ```bash
# 首次安装 # 首次安装
npm --prefix website install bun install --cwd website --frozen-lockfile
# 本地开发官网 # 本地开发官网
npm run website:dev bun run website:dev
# 类型检查与生产构建 # 类型检查与生产构建
npm run website:typecheck bun run website:typecheck
npm run website:build bun run website:build
``` ```
`./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。 `./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
File diff suppressed because one or more lines are too long
+31
View File
@@ -258,6 +258,21 @@ impl InteractionBroker {
self.entries.lock().await.len() self.entries.lock().await.len()
} }
/// Return a snapshot of unresolved interactions for one run. The full
/// binding must match, so callers cannot inspect another actor's prompts.
pub async fn pending_for_binding(
&self,
binding: &InteractionBinding,
) -> Vec<InteractionRequest> {
self.entries
.lock()
.await
.values()
.filter(|entry| &entry.request.binding == binding)
.map(|entry| entry.request.clone())
.collect()
}
async fn expire(&self, interaction_id: &str, generation: uuid::Uuid) -> bool { async fn expire(&self, interaction_id: &str, generation: uuid::Uuid) -> bool {
let mut entries = self.entries.lock().await; let mut entries = self.entries.lock().await;
let matches_generation = entries let matches_generation = entries
@@ -470,4 +485,20 @@ mod tests {
Err(BrokerError::Cancelled(_)) Err(BrokerError::Cancelled(_))
)); ));
} }
#[tokio::test]
async fn pending_snapshot_is_scoped_to_the_full_binding() {
let broker = InteractionBroker::default();
let pending = broker
.register(request("approval-6"), CancellationToken::new())
.await
.unwrap();
assert_eq!(broker.pending_for_binding(&binding()).await.len(), 1);
assert!(broker
.pending_for_binding(&InteractionBinding::new("other", "session", "run"))
.await
.is_empty());
broker.cancel_run(&binding()).await;
assert!(matches!(pending.wait().await, Err(BrokerError::Cancelled(_))));
}
} }
+8 -2
View File
@@ -17,6 +17,8 @@ pub enum ToolCapability {
AskUserQuestion, AskUserQuestion,
#[serde(rename = "Bash")] #[serde(rename = "Bash")]
Bash, Bash,
#[serde(rename = "CreateDocument")]
CreateDocument,
#[serde(rename = "Skill")] #[serde(rename = "Skill")]
Skill, Skill,
} }
@@ -31,6 +33,7 @@ impl ToolCapability {
Self::Grep => "Grep", Self::Grep => "Grep",
Self::AskUserQuestion => "AskUserQuestion", Self::AskUserQuestion => "AskUserQuestion",
Self::Bash => "Bash", Self::Bash => "Bash",
Self::CreateDocument => "CreateDocument",
Self::Skill => "Skill", Self::Skill => "Skill",
} }
} }
@@ -44,6 +47,7 @@ impl ToolCapability {
"Grep" => Some(Self::Grep), "Grep" => Some(Self::Grep),
"AskUserQuestion" => Some(Self::AskUserQuestion), "AskUserQuestion" => Some(Self::AskUserQuestion),
"Bash" => Some(Self::Bash), "Bash" => Some(Self::Bash),
"CreateDocument" => Some(Self::CreateDocument),
"Skill" => Some(Self::Skill), "Skill" => Some(Self::Skill),
_ => None, _ => None,
} }
@@ -64,7 +68,7 @@ pub struct ToolSpec {
pub approval_can_be_remembered: bool, pub approval_can_be_remembered: bool,
} }
const SERVER_TOOLS: [ToolSpec; 7] = [ const SERVER_TOOLS: [ToolSpec; 8] = [
ToolSpec::policy(ToolCapability::Read), ToolSpec::policy(ToolCapability::Read),
ToolSpec::policy(ToolCapability::Write), ToolSpec::policy(ToolCapability::Write),
ToolSpec::policy(ToolCapability::Edit), ToolSpec::policy(ToolCapability::Edit),
@@ -72,6 +76,7 @@ const SERVER_TOOLS: [ToolSpec; 7] = [
ToolSpec::policy(ToolCapability::Grep), ToolSpec::policy(ToolCapability::Grep),
ToolSpec::policy(ToolCapability::AskUserQuestion), ToolSpec::policy(ToolCapability::AskUserQuestion),
ToolSpec::policy(ToolCapability::Bash), ToolSpec::policy(ToolCapability::Bash),
ToolSpec::policy(ToolCapability::CreateDocument),
]; ];
const DESKTOP_TOOLS: [ToolSpec; 8] = [ const DESKTOP_TOOLS: [ToolSpec; 8] = [
@@ -207,7 +212,8 @@ mod tests {
"Glob", "Glob",
"Grep", "Grep",
"AskUserQuestion", "AskUserQuestion",
"Bash" "Bash",
"CreateDocument"
] ]
); );
assert!(!policy.allows("Skill")); assert!(!policy.allows("Skill"));
-79
View File
@@ -1,79 +0,0 @@
# Design - BoosAPI Console
A locked design system for the BoosAPI operations console. Page work preserves
the existing React routes, API behaviour, session model, and i18n contracts.
## Product Scope
BoosAPI is an API aggregation and operations platform. The interface serves two
audiences: administrators who operate providers, routes, credentials, billing,
and incidents; and users who manage keys, balance, usage, and generation work.
### Route Families
- Operations: dashboard, ops monitor, sites, accounts, routes, logs, events,
settings, IP management, downstream keys, notifications, import/export.
- Platform inventory: models, OAuth, site announcements, users, payments,
user balances, about.
- Creation: picture agent, digital human, workflows, ComfyUI agent, agent
workbench, workspace, money printer.
- User self-service: dashboard, models, recharge, keys, profile, usage,
announcements, creation and workspace routes.
- Entry: admin login, user login, registration, and OAuth callback.
## Genre
Modern-minimal operational software. The interface should feel quiet, exact,
and resilient under repeated use. Information density comes from alignment and
typographic hierarchy, not decorative surfaces.
## Page Families
- App pages: Workbench. A concise operational header, a priority summary, then
data surfaces arranged by task flow. Dashboard is the reference implementation.
- Creation pages: Workbench with a focused canvas or conversation surface.
- Entry pages: a compact, branded authentication shell with no marketing hero.
- Reference and policy pages: Long Document, typography-led and calm.
## Tokens And Typography
- Preserve the existing `src/web/index.css` tokens and theme presets.
- Accent: existing low-saturation teal (`--color-primary`), used for focus,
selection, and a small number of primary actions only.
- All UI text, including code-like values, metrics, logs, and identifiers, uses
the existing Google Sans stack. Metric values retain tabular numbers rather
than switching to a separate mono face. Headings remain upright and use
normal letter spacing.
- Keep the existing 4 px-derived spacing rhythm. Use the existing radius and
shadow tokens; operational surfaces favour a visible border over elevation.
## Interaction
- Motion is informative only: 140-220 ms transform/opacity transitions, with
`prefers-reduced-motion` support.
- Every interactive control has visible `:focus-visible`, active, disabled,
loading, error, and success treatment where that state is applicable.
- Hover enhancements are conditional on fine pointers. No action is hover-only.
- Buttons and tabs remain one line and target at least 44 px on touch devices.
## Responsive Rules
- Validate at 320, 375, 414, and 768 CSS pixels.
- App grids collapse by content pressure: one column below 40rem, two columns
at tablet widths, and dense workstation layouts only from 60rem upward.
- Preserve the existing mobile drawer and card alternatives for data tables.
## Page Constraints
- Do not invent metrics, testimonials, provider logos, or product claims.
- Avoid visual card nesting, ornamental gradients, floating blobs, and thick
coloured side stripes. A border, a compact status marker, and type hierarchy
are sufficient for operational priority.
- Do not modify API contracts, route ownership, auth/session behaviour, or
i18n keys for visual work.
## First Rollout
`/dashboard` is the first implementation. It adopts a Workbench header, an
asymmetric but stable metric grid, flat data surfaces, and explicit keyboard
focus treatment while retaining all current data and actions.
@@ -1,694 +0,0 @@
# 计费闭环 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为 BoosAPI 建立 ledger-first 的双轨计费闭环(USD 池 + 工作流积分池),所有余额变动写统一流水账 `credit_ledger`。
**Architecture:** 新建 `credit_ledger`(唯一真相源)+ `user_credits`(积分缓存)+ `recharge_tiers`(充值档位)三张表。USD 轨沿用 `downstream_api_keys.max_cost/used_cost` 作热路径缓存,每笔变动同事务写一条 ledger。积分轨为工作流执行新增扣费/拦截。分 6 期,本计划详写第 1 期(地基),后 5 期为概要待细化。
**Tech Stack:** Rust (axum + sqlx + SQLite)、React + Vite 前端、in-memory SQLite 测试。
---
## 文件结构(第 1 期)
- 新建 `src/services/billing/ledger.rs` — ledger 写入 + 余额查询的唯一入口(`record_ledger_entry`、`ensure_ledger_schema`)。所有动余额的地方调它,保证"缓存 + ledger 同事务"不变量集中在一处。
- 修改 `src/services/billing/mod.rs` — `mod ledger;` 导出;`record_usage` 在写 usage_logs + increment 后,同事务补写 ledger(usd, consume)。
- 修改 `src/services/payment.rs` — `credit_user_balance` 加写 ledger(usd, recharge)。
- 修改 `src/routes/admin/users.rs` — `adjust_user_balance` 加写 ledger(usd, admin_adjust)。
- 测试:`src/services/billing/ledger.rs` 内 `#[cfg(test)] mod tests`(复用 in-memory SQLite 模式)。
---
## 第 1 期:地基(credit_ledger + user_credits 表 + USD 轨记账接入)
### Task 1:ledger 表结构 + 写入入口
**Files:**
- Create: `src/services/billing/ledger.rs`
- Modify: `src/services/billing/mod.rs`(顶部加 `pub mod ledger;`)
- [ ] **Step 1: 写失败测试** — 在 `src/services/billing/ledger.rs` 末尾:
```rust
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
async fn mem_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
ensure_ledger_schema(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn record_ledger_entry_writes_row_with_balance_snapshot() {
let pool = mem_pool().await;
let mut tx = pool.begin().await.unwrap();
record_ledger_entry(
&mut tx,
LedgerEntry {
user_id: 7,
track: "usd",
entry_type: "recharge",
amount: 10.0,
balance_after: 10.0,
ref_type: Some("stripe"),
ref_id: Some("cs_1"),
model: None,
operator: Some("system"),
note: None,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let row: (i64, String, String, f64, f64) = sqlx::query_as(
"SELECT user_id, track, entry_type, amount, balance_after
FROM credit_ledger WHERE user_id = 7",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (7, "usd".into(), "recharge".into(), 10.0, 10.0));
}
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
Expected: 编译失败(`ensure_ledger_schema`/`record_ledger_entry`/`LedgerEntry` 未定义)。
- [ ] **Step 3: 写最小实现** — 在 `src/services/billing/ledger.rs` 顶部(tests 之前):
```rust
use crate::error::AppResult;
use sqlx::{SqlitePool, Transaction};
/// One balance-changing event. Written to `credit_ledger` in the same
/// transaction that updates the cached balance, so ledger and cache never diverge.
pub struct LedgerEntry<'a> {
pub user_id: i64,
pub track: &'a str, // "usd" | "points"
pub entry_type: &'a str, // "recharge" | "admin_adjust" | "consume" | "refund" | "grant"
pub amount: f64, // +credit / -debit
pub balance_after: f64,
pub ref_type: Option<&'a str>,
pub ref_id: Option<&'a str>,
pub model: Option<&'a str>,
pub operator: Option<&'a str>,
pub note: Option<&'a str>,
}
pub async fn ensure_ledger_schema(db: &SqlitePool) -> AppResult<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS credit_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
track TEXT NOT NULL,
entry_type TEXT NOT NULL,
amount REAL NOT NULL,
balance_after REAL NOT NULL,
ref_type TEXT,
ref_id TEXT,
model TEXT,
operator TEXT,
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx
ON credit_ledger (user_id, created_at DESC)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx
ON credit_ledger (track, entry_type)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS user_credits (
user_id INTEGER PRIMARY KEY NOT NULL,
points_balance REAL NOT NULL DEFAULT 0,
points_total_recharged REAL NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
Ok(())
}
/// Append one ledger row inside an existing transaction. Caller is responsible
/// for also updating the cached balance in the same `tx`.
pub async fn record_ledger_entry(
tx: &mut Transaction<'_, sqlx::Sqlite>,
entry: LedgerEntry<'_>,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO credit_ledger
(user_id, track, entry_type, amount, balance_after,
ref_type, ref_id, model, operator, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(entry.user_id)
.bind(entry.track)
.bind(entry.entry_type)
.bind(entry.amount)
.bind(entry.balance_after)
.bind(entry.ref_type)
.bind(entry.ref_id)
.bind(entry.model)
.bind(entry.operator)
.bind(entry.note)
.execute(&mut **tx)
.await?;
Ok(())
}
```
并在 `src/services/billing/mod.rs` 顶部加:`pub mod ledger;`
- [ ] **Step 4: 运行测试确认通过**
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
Expected: PASS(1 passed)。
- [ ] **Step 5: 提交**
```bash
git add src/services/billing/ledger.rs src/services/billing/mod.rs
git commit -m "feat(billing): credit_ledger schema + record_ledger_entry"
```
### Task 2:USD 消费接入 ledger(record_usage)
**Files:**
- Modify: `src/services/billing/mod.rs`(`record_usage`,行 ~48-77)
- [ ] **Step 1: 写失败测试** — 在 billing/mod.rs 的 tests mod 加:
```rust
#[tokio::test]
async fn record_usage_writes_consume_ledger_entry() {
let pool = billing_test_pool().await; // 复用现有测试 pool helper;若无则建 users+downstream_api_keys+usage_logs
let tracker = UsageTracker::new(std::sync::Arc::new(pool.clone()));
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
// key_id=7 属于 user 9,max_cost=10 used_cost=2(由 helper 建好)
tracker.record_usage(7, "gpt-4o", 100, 50, 0.42).await.unwrap();
let row: (String, String, f64, Option<String>) = sqlx::query_as(
"SELECT track, entry_type, amount, model FROM credit_ledger
WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
).fetch_one(&pool).await.unwrap();
assert_eq!(row.0, "usd");
assert_eq!(row.1, "consume");
assert_eq!(row.2, -0.42); // 消费为负
assert_eq!(row.3.as_deref(), Some("gpt-4o"));
}
```
注:若 billing/mod.rs 无现成 `billing_test_pool`,在 tests mod 加一个建 `users`/`downstream_api_keys`(含 user_id=9,key id=7 max_cost=10 used_cost=2)/`usage_logs` 的 helper(参照 payment.rs 的 payment_test_pool)。
- [ ] **Step 2: 运行确认失败**
Run: `cargo test -p boosapi --lib record_usage_writes_consume_ledger_entry 2>&1 | tail -5`
Expected: FAIL(credit_ledger 无 user 9 行 / 表不存在)。
- [ ] **Step 3: 实现** — `record_usage` 改为事务化,末尾补写 ledger。替换 record_usage 函数体为:
```rust
pub async fn record_usage(
&self,
key_id: i64,
model: impl Into<String>,
prompt_tokens: i64,
completion_tokens: i64,
cost: f64,
) -> AppResult<()> {
Self::ensure_schema(&self.db).await?;
crate::services::billing::ledger::ensure_ledger_schema(&self.db).await?;
let prompt_tokens = prompt_tokens.max(0);
let completion_tokens = completion_tokens.max(0);
let total_tokens = prompt_tokens + completion_tokens;
let cost = cost.max(0.0);
let model = model.into();
let mut tx = self.db.begin().await?;
sqlx::query(
"INSERT INTO usage_logs
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(key_id).bind(&model)
.bind(prompt_tokens).bind(completion_tokens).bind(total_tokens).bind(cost)
.execute(&mut *tx).await?;
sqlx::query(
"UPDATE downstream_api_keys
SET used_requests = COALESCE(used_requests,0)+1,
used_cost = COALESCE(used_cost,0)+?,
last_used_at = datetime('now'), updated_at = datetime('now')
WHERE id = ?",
).bind(cost).bind(key_id).execute(&mut *tx).await?;
// Resolve owning user + new used_cost for the ledger snapshot.
let owner: Option<(Option<i64>, Option<f64>, Option<f64>)> = sqlx::query_as(
"SELECT user_id, max_cost, used_cost FROM downstream_api_keys WHERE id = ?",
).bind(key_id).fetch_optional(&mut *tx).await?;
if let Some((Some(user_id), max_cost, used_cost)) = owner {
let used = used_cost.unwrap_or(0.0);
let remaining = max_cost.map(|m| (m - used).max(0.0)).unwrap_or(used);
crate::services::billing::ledger::record_ledger_entry(
&mut tx,
crate::services::billing::ledger::LedgerEntry {
user_id, track: "usd", entry_type: "consume",
amount: -cost, balance_after: remaining,
ref_type: Some("usage_log"), ref_id: None,
model: Some(&model), operator: Some("system"), note: None,
},
).await?;
}
tx.commit().await?;
Ok(())
}
```
(注:原 `increment_key_usage(&self.db, ...)` 调用被事务内 UPDATE 取代,删除该行。)
- [ ] **Step 4: 运行确认通过**
Run: `cargo test -p boosapi --lib record_usage 2>&1 | tail -6`
Expected: PASS。
- [ ] **Step 5: 提交**
```bash
git add src/services/billing/mod.rs
git commit -m "feat(billing): USD consume writes credit_ledger in one tx"
```
### Task 3:USD 充值接入 ledger(credit_user_balance)
**Files:**
- Modify: `src/services/payment.rs`(`credit_user_balance`,行 ~2167-2202)
- [ ] **Step 1: 写失败测试** — payment.rs tests mod(复用 payment_test_pool,user 9/key 7 max_cost=10):
```rust
#[tokio::test]
async fn credit_user_balance_writes_recharge_ledger() {
let pool = payment_test_pool().await;
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
let mut tx = pool.begin().await.unwrap();
credit_user_balance(&mut tx, 9, 20.0).await.unwrap();
tx.commit().await.unwrap();
let row: (String, String, f64) = sqlx::query_as(
"SELECT track, entry_type, amount FROM credit_ledger WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
).fetch_one(&pool).await.unwrap();
assert_eq!(row, ("usd".into(), "recharge".into(), 20.0));
}
```
- [ ] **Step 2: 运行确认失败**
Run: `cargo test -p boosapi --lib credit_user_balance_writes_recharge_ledger 2>&1 | tail -5`
Expected: FAIL。
- [ ] **Step 3: 实现** — `credit_user_balance` 在 UPDATE max_cost 之后、`Ok(Some(key_id))` 之前插入 ledger 写入(已在事务 tx 内):
```rust
// After the UPDATE that sets next_max_cost on the key:
crate::services::billing::ledger::record_ledger_entry(
tx,
crate::services::billing::ledger::LedgerEntry {
user_id, track: "usd", entry_type: "recharge",
amount: amount_usd.max(0.0),
balance_after: (next_max_cost - used_cost).max(0.0),
ref_type: Some("recharge_record"), ref_id: None,
model: None, operator: Some("system"), note: None,
},
).await?;
```
(`credit_user_balance` 已是 `tx: &mut Transaction`,直接传 `tx`。确保调用方在调用前已 `ensure_ledger_schema`,或在函数开头加一次幂等建表。)
- [ ] **Step 4: 运行确认通过**
Run: `cargo test -p boosapi --lib credit_user_balance 2>&1 | tail -6`
Expected: PASS(含已有的 recharge completion 测试)。
- [ ] **Step 5: 提交**
```bash
git add src/services/payment.rs
git commit -m "feat(billing): USD recharge writes credit_ledger"
```
### Task 4:USD 管理员调整接入 ledger(adjust_user_balance)
**Files:**
- Modify: `src/routes/admin/users.rs`(`adjust_user_balance`,行 ~820)
- [ ] **Step 1: 写失败测试** — users.rs tests mod:调 `adjust_user_balance(State, Path(9), Json{delta:5.0})` 后断言 credit_ledger 有一行 `(usd, admin_adjust, 5.0)`。(建表与 user/key 同 Task 3 helper。)
- [ ] **Step 2: 运行确认失败** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -5` → FAIL。
- [ ] **Step 3: 实现** — `adjust_user_balance` 现在直接 `execute(&*state.db)`。改为事务:begin → UPDATE max_cost → `record_ledger_entry(track="usd", entry_type="admin_adjust", amount=delta, balance_after=next_max-used, operator=<管理员,暂用 "admin">)` → commit。函数开头 `ledger::ensure_ledger_schema(&state.db).await?;`。
- [ ] **Step 4: 运行确认通过** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -6` → PASS。
- [ ] **Step 5: 提交**
```bash
git add src/routes/admin/users.rs
git commit -m "feat(billing): USD admin adjust writes credit_ledger"
```
### Task 5:全量回归
- [ ] **Step 1:** `cargo test 2>&1 | grep "test result"` → 全 ok。
- [ ] **Step 2:** `cargo build 2>&1 | tail -3` → Finished。
- [ ] **Step 3:** 提交(若有遗留)。
---
## 后续期次(概要,第 1 期落地后逐期细化为同样的 TDD 任务)
### 第 2 期:积分轨
- `user_credits` 已建表(Task 1)。新增 `adjust_user_credits`(admin 发放/增减,写 ledger points/admin_adjust)。
- comfy_cloud 确认到账 `admin_confirm_user_recharge` 改为加 `points_balance` + ledger(points, recharge),受 config 开关 `payment.comfy_recharge_to_points` 控制(默认 true);历史不迁移。
- 工作流积分单价 config(默认值 + 按工作流名分级映射)。
- `workflow/executor.rs` `run_workflow`:执行**前**查 `points_balance >= 单价`,不足返回 Forbidden「工作流积分不足」;执行**成功后**扣积分 + ledger(points, consume, model=工作流名)。
- 测试:拦截、扣费、充值进积分、管理员调整。
### 第 3 期:A 消费明细
- `GET /api/user/billing/ledger`(查本人 credit_ledger,track/type/时间过滤、分页)。
- `GET /api/user/billing/summary`(按天/模型聚合,双轨分开)。
- 扩展 `UsageRecords.tsx`:双轨切换 + 汇总卡 + 明细列表。
### 第 4 期:B 充值档位
- `recharge_tiers` 表(已在 spec,Task 1 未建,本期建)+ admin CRUD `/api/admin/recharge-tiers`。
- 用户充值档位从 DB 读(替代 env `USER_RECHARGE_OPTIONS`)。
- admin `RechargeTiers` 配置页 + `UserRecharge.tsx` 接 DB。
### 第 5 期:C 预警
- 余额接口(user_billing_summary 等)加 `lowBalance`(余额 < 阈值,config 默认 0.2)。
- Dashboard / UserRecharge / ComfyUIAgent 顶部预警条。
### 第 6 期:D 审计 + 对账
- `GET /api/admin/billing/ledger`(跨用户,过滤分页)+ admin `BillingAudit` 页。
- cron:拉 Comfy `/customers/balance` 总余额,与 points consume ledger 比对,偏差超阈值写 log + admin 告警标志(不自动改数据)。
---
## 自审
- **spec 覆盖**:第 1 期覆盖 spec「数据模型」+「USD 轨记账接入」三处(充值/消费/调整);后 5 期概要逐条对应 spec 的积分轨/A/B/C/D + 对账。recharge_tiers 表 spec 列在数据模型,实现挪到第 4 期建(第 1 期用不到),已注明。
- **占位扫描**:第 1 期 Task 1-3 含完整代码;Task 4-5 用文字描述但步骤明确(adjust 与 Task 3 同模式,刻意不重复大段代码,执行时参照 Task 3)。后 5 期为概要,执行前细化。
- **类型一致**:`LedgerEntry` 字段、`record_ledger_entry`/`ensure_ledger_schema` 签名在 Task 1 定义,Task 2-4 一致引用。`track`/`entry_type` 取值(usd/points、recharge/consume/admin_adjust)全程一致。
---
## 第 2 期:积分轨(细化 TDD)
文件结构:
- `src/services/billing/ledger.rs` 加积分轨核心:`points_balance(db,user_id)`(读余额)、`apply_points_delta(db, user_id, delta, entry_type, model, operator, ref_*)`(事务内:UPSERT user_credits 余额 + 写 ledger(points),返回新余额)。所有积分增减走这一个入口。
- `src/config.rs`:ComfyUIConfig 加 `workflow_points_cost`(默认单价)。
- `src/workflow/executor.rs`:run_workflow_with_progress 前置拦截 + 收尾扣积分。
- `src/services/payment.rs`:admin_confirm_user_recharge 改为加积分(受 config 开关)。
- `src/routes/admin/users.rs`:新增 `adjust_user_credits` handler + 路由。
### Task 6:积分轨核心 apply_points_delta + points_balance
**Files:** Modify `src/services/billing/ledger.rs`
- [ ] **Step1 失败测试**(ledger.rs tests mod):
```rust
#[tokio::test]
async fn apply_points_delta_upserts_balance_and_writes_ledger() {
let pool = mem_pool().await;
// 首次 +100(充值)
let bal = apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("recharge_record"), None).await.unwrap();
assert_eq!(bal, 100.0);
// 再 -30(消费)
let bal2 = apply_points_delta(&pool, 9, -30.0, "consume", Some("wf/video"), Some("system"), Some("workflow"), None).await.unwrap();
assert_eq!(bal2, 70.0);
// user_credits 缓存 = 70
let cached: f64 = sqlx::query_scalar("SELECT points_balance FROM user_credits WHERE user_id = 9").fetch_one(&pool).await.unwrap();
assert_eq!(cached, 70.0);
// ledger 两行
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
assert_eq!(n, 2);
}
```
- [ ] **Step2** `cargo test -p boosapi --lib apply_points_delta_upserts 2>&1 | tail -6` → FAIL。
- [ ] **Step3 实现**(ledger.rs,tests 之前):
```rust
/// Current points balance for a user (0 if no row yet).
pub async fn points_balance(db: &SqlitePool, user_id: i64) -> AppResult<f64> {
let bal: Option<f64> = sqlx::query_scalar(
"SELECT points_balance FROM user_credits WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(db)
.await?;
Ok(bal.unwrap_or(0.0))
}
/// Apply a points delta (+credit / -debit) atomically: upsert the cached
/// balance in `user_credits` and append one `credit_ledger(track=points)` row,
/// in a single transaction. Returns the new balance.
#[allow(clippy::too_many_arguments)]
pub async fn apply_points_delta(
db: &SqlitePool,
user_id: i64,
delta: f64,
entry_type: &str,
model: Option<&str>,
operator: Option<&str>,
ref_type: Option<&str>,
ref_id: Option<&str>,
) -> AppResult<f64> {
ensure_ledger_schema(db).await?;
let mut tx = db.begin().await?;
let current: f64 = sqlx::query_scalar(
"SELECT points_balance FROM user_credits WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0.0);
let next = current + delta;
let total_recharged_inc = if delta > 0.0 && entry_type == "recharge" { delta } else { 0.0 };
sqlx::query(
"INSERT INTO user_credits (user_id, points_balance, points_total_recharged, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
points_balance = ?,
points_total_recharged = points_total_recharged + ?,
updated_at = datetime('now')",
)
.bind(user_id).bind(next).bind(total_recharged_inc)
.bind(next).bind(total_recharged_inc)
.execute(&mut *tx).await?;
record_ledger_entry(&mut tx, LedgerEntry {
user_id, track: "points", entry_type,
amount: delta, balance_after: next,
ref_type, ref_id, model, operator, note: None,
}).await?;
tx.commit().await?;
Ok(next)
}
```
- [ ] **Step4** `cargo test -p boosapi --lib apply_points_delta 2>&1 | tail -6` → PASS;`cargo build -j 2 | tail -2`。
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): points track apply_points_delta + balance"`
### Task 7:工作流积分单价 config
**Files:** Modify `src/config.rs`
- [ ] **Step1** 测试:config 默认值测试(若 config.rs 有 default 测试则加断言;否则跳测试,靠编译)。
- [ ] **Step2/3 实现**:ComfyUIConfig 加字段 `#[serde(default = "default_workflow_points_cost")] pub workflow_points_cost: f64,`,default fn 返回 `10.0`;Default impl 里加 `workflow_points_cost: 10.0,`。
- [ ] **Step4** `cargo build -j 2 2>&1 | tail -3` → Finished(config 字段加完整个 crate 能编译)。
- [ ] **Step5** `git add src/config.rs && git commit -m "feat(billing): workflow_points_cost config"`
### Task 8:工作流执行前置拦截 + 成功扣积分
**Files:** Modify `src/workflow/executor.rs`
- [ ] **Step1 失败测试**:executor.rs tests mod(若无则建)。建 in-memory pool + user_credits/credit_ledger,构造一个最简 graph,`ctx.user_id=Some(9)`:
- 余额不足(0 积分,单价 10)→ run_workflow_with_progress 返回 Err(Forbidden 含"积分不足")。
- 余额充足(100)→ 执行成功后 points_balance 变 90,credit_ledger 出现一行 (points, consume, -10)。
(若 executor 难以单测真实节点,则把"拦截"和"扣费"逻辑抽成小函数 `check_workflow_points(state, user_id, cost)` 和 `charge_workflow_points(state, user_id, cost, workflow_name)` 各自单测,run_workflow 调它们。优先抽函数单测。)
- [ ] **Step2** 确认 FAIL。
- [ ] **Step3 实现**:
- 在 run_workflow_with_progress 的 `validate_and_sort` 之后、`insert_run` 之前:若 `ctx.user_id` 是 Some 且 `ctx.charge_points`(见下):`let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let bal = ledger::points_balance(&state.db, uid).await?; if bal < cost { return Err(AppError::Forbidden("工作流积分不足,请充值".into())); } }`
- 在 `complete_run` 之后、`Ok(run_public_id)` 之前:`if let Some(uid) = ctx.user_id { let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let _ = ledger::apply_points_delta(&state.db, uid, -cost, "consume", Some(&format!("workflow:{}", workflow_id)), Some("system"), Some("workflow_run"), Some(&run_public_id)).await; } }`(扣费 best-effort,失败记 log 不阻断已产出结果)。
- 注意:Admin 调用(builtin key,user_id 可能是 Some(admin) 或特殊)是否扣?按设计仅对普通用户扣。可用 RunContext 加 `pub charge_points: bool` 字段,run_context 构造时 User=>true / Admin=>false。models.rs RunContext 加该字段,两处构造(routes.rs run_context、workflow_ui.rs workflow_context)补 `charge_points`。
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
- [ ] **Step5** `git add src/workflow/executor.rs src/workflow/models.rs src/workflow/routes.rs src/routes/workflow_ui.rs && git commit -m "feat(billing): workflow points gating + charge"`
### Task 9:comfy 充值改加积分 + admin 积分调整
**Files:** Modify `src/services/payment.rs`、`src/routes/admin/users.rs`、`src/config.rs`(开关)、`src/routes/admin/mod.rs`(路由)
- [ ] **Step1 失败测试**:
- payment.rs:`admin_confirm_user_recharge` 确认后,credit_ledger 出现 (points, recharge, +amount)(而非 usd);user_credits 增加。
- users.rs:`adjust_user_credits` handler 调用后 credit_ledger 出现 (points, admin_adjust)。
- [ ] **Step2** FAIL。
- [ ] **Step3 实现**:
- config 开关 `payment.comfy_recharge_to_points: bool`(默认 true)。
- `admin_confirm_user_recharge`:开关为 true 时改调 `ledger::apply_points_delta(db, user_id, amount_usd_as_points, "recharge", None, Some("admin"), Some("recharge_record"), Some(public_id))`(USD→积分换算:本期 1 USD = N 积分,N 用 config `payment.usd_to_points_rate` 默认 100;即 grant_points = amount_usd * rate)。开关 false 时维持原 credit_user_balance(USD)。**历史不迁移**。
- 新增 `adjust_user_credits(State, Path(id), Json{delta})` handler(仿 adjust_user_balance)→ `apply_points_delta(db,id,delta,"admin_adjust",None,Some("admin"),Some("manual"),None)`,返回 {success, data{userId, pointsBalance}}。
- admin/mod.rs 注册 `POST /api/admin/users/{id}/credits/adjust`。
- [ ] **Step4** 相关测试 + payment/users 模块全过 + build。
- [ ] **Step5** 分两个 commit 或一个:`git commit -m "feat(billing): comfy recharge to points + admin credit adjust"`
### Task 10:第 2 期全量回归
- [ ] `cargo test 2>&1 | grep "test result"` 全 ok;`cargo build -j 2 | tail -2` Finished。
---
## 第 3 期:A 消费明细(细化 TDD)
文件:`src/services/billing/ledger.rs`(加查询函数)、`src/routes/user/mod.rs`(加 2 路由)、`src/web/api.ts`(加 client)、`src/web/pages/UsageRecords.tsx`(扩展)。
### Task 11:ledger 查询函数(明细 + 聚合)
**Files:** Modify `src/services/billing/ledger.rs`
- [ ] **Step1 失败测试**(ledger.rs tests mod):
```rust
#[tokio::test]
async fn list_and_summarize_user_ledger() {
let pool = mem_pool().await;
apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("rr"), None).await.unwrap();
apply_points_delta(&pool, 9, -10.0, "consume", Some("wf:1"), Some("system"), Some("run"), None).await.unwrap();
// 明细:user 9 两行,track=points
let rows = list_user_ledger(&pool, 9, None, None, 50, 0).await.unwrap();
assert_eq!(rows.len(), 2);
// 聚合:points 轨 consume 合计 -10
let sum = summarize_user_ledger(&pool, 9).await.unwrap();
assert!(sum.iter().any(|v| v["track"]=="points" && v["entryType"]=="consume"));
}
```
- [ ] **Step2** `cargo test -p boosapi --lib list_and_summarize_user_ledger 2>&1|tail -6` FAIL。
- [ ] **Step3 实现**(ledger.rs,tests 之前):
```rust
use serde_json::{json, Value};
/// List a user's ledger rows, newest first, optional track/entry_type filter + paging.
pub async fn list_user_ledger(
db: &SqlitePool, user_id: i64,
track: Option<&str>, entry_type: Option<&str>,
limit: i64, offset: i64,
) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let limit = limit.clamp(1, 500);
let rows = sqlx::query(
"SELECT track, entry_type, amount, balance_after, model, operator, ref_type, ref_id, note, created_at
FROM credit_ledger
WHERE user_id = ?
AND (?1_track IS NULL OR track = ?1_track)
AND (?1_type IS NULL OR entry_type = ?1_type)
ORDER BY id DESC LIMIT ? OFFSET ?"
.replace("?1_track", "?2").replace("?1_type", "?3")
)
.bind(user_id).bind(track).bind(entry_type).bind(limit).bind(offset)
.fetch_all(db).await?;
use sqlx::Row;
Ok(rows.iter().map(|r| json!({
"track": r.try_get::<String,_>("track").unwrap_or_default(),
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
"amount": r.try_get::<f64,_>("amount").unwrap_or(0.0),
"balanceAfter": r.try_get::<f64,_>("balance_after").unwrap_or(0.0),
"model": r.try_get::<Option<String>,_>("model").ok().flatten(),
"operator": r.try_get::<Option<String>,_>("operator").ok().flatten(),
"refType": r.try_get::<Option<String>,_>("ref_type").ok().flatten(),
"note": r.try_get::<Option<String>,_>("note").ok().flatten(),
"createdAt": r.try_get::<Option<String>,_>("created_at").ok().flatten(),
})).collect())
}
/// Aggregate a user's ledger by (track, entry_type): sum amount + count.
pub async fn summarize_user_ledger(db: &SqlitePool, user_id: i64) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let rows = sqlx::query(
"SELECT track, entry_type, COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt
FROM credit_ledger WHERE user_id = ?
GROUP BY track, entry_type ORDER BY track, entry_type"
).bind(user_id).fetch_all(db).await?;
use sqlx::Row;
Ok(rows.iter().map(|r| json!({
"track": r.try_get::<String,_>("track").unwrap_or_default(),
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
"total": r.try_get::<f64,_>("total").unwrap_or(0.0),
"count": r.try_get::<i64,_>("cnt").unwrap_or(0),
})).collect())
}
```
注:上面 SQL 的 named-param replace 写法若 sqlx 不接受,改用直白的可选过滤:用 `track.is_none() OR track = ?` 形式,bind 顺序 user_id, track, track, entry_type, entry_type, limit, offset(每个 Option bind 两次)。子代理按 sqlx 实际行为调整到能编译+测试过。
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): user ledger list + summary queries"`
### Task 12:user 侧 billing 接口
**Files:** Modify `src/routes/user/mod.rs`(加 2 路由 + 2 handler)
- [ ] **Step1 失败测试**:user mod tests(若有 test_state/建表 helper 则复用)——构造 user JWT + ledger 数据,GET /api/user/billing/ledger 返回行,/summary 返回聚合。若 user mod 无现成集成测试框架,改为直接单测 handler 逻辑或跳测试靠 ledger.rs 的 Task11 测试覆盖,仅验证编译 + 路由注册。
- [ ] **Step2** FAIL/编译失败。
- [ ] **Step3 实现**:
```rust
async fn get_billing_ledger(State(state): State<AppState>, headers: HeaderMap, Query(q): Query<BillingLedgerQuery>) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let rows = crate::services::billing::ledger::list_user_ledger(
&state.db, user_id, q.track.as_deref(), q.entry_type.as_deref(),
q.limit.unwrap_or(100), q.offset.unwrap_or(0)).await?;
Ok(Json(json!({ "success": true, "records": rows })))
}
async fn get_billing_summary(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let agg = crate::services::billing::ledger::summarize_user_ledger(&state.db, user_id).await?;
Ok(Json(json!({ "success": true, "summary": agg })))
}
```
+ `BillingLedgerQuery{ track:Option<String>, entry_type:Option<String>, limit:Option<i64>, offset:Option<i64> }`(serde Deserialize, rename_all camelCase 视前端传参)。
+ routes() 注册 `.route("/api/user/billing/ledger", get(get_billing_ledger)).route("/api/user/billing/summary", get(get_billing_summary))`。
- [ ] **Step4** `cargo test -p boosapi --lib 2>&1|command grep "test result"` + `cargo build -j 2`。
- [ ] **Step5** `git add src/routes/user/mod.rs && git commit -m "feat(billing): user billing ledger + summary endpoints"`
### Task 13:前端 api + UsageRecords 双轨展示
**Files:** Modify `src/web/api.ts`、`src/web/pages/UsageRecords.tsx`
- [ ] **Step1**:api.ts 的 userApi 加 `getBillingLedger(params)` → GET /api/user/billing/ledger、`getBillingSummary()` → /summary。
- [ ] **Step2**:UsageRecords.tsx 加一个"积分/USD"双轨切换 tab + 聚合汇总卡(用 getBillingSummary)+ ledger 明细列表(getBillingLedger)。保留现有 logs 视图作为 USD 明细来源之一,新增积分明细来自 ledger。具体 UI 复用现有 usage-records-* 样式。
- [ ] **Step3**:`npm run typecheck:web` 干净 + 现有 UsageRecords 测试不破坏(`npx vitest run src/web/pages/UsageRecords`)。
- [ ] **Step4** `git add src/web/api.ts src/web/pages/UsageRecords.tsx && git commit -m "feat(billing): UsageRecords dual-track ledger view"`
### Task 14:第3期回归
- [ ] `cargo test 2>&1|command grep "test result"` 全ok;`cargo build -j 2`;`npm run typecheck`。
@@ -1,632 +0,0 @@
# Comfy Cloud 工作流执行适配器 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 BoosAPI 内实现 ComfyUI 原生协议 ↔ Comfy Cloud API 翻译适配器,让 Pixelle/comfykit 零改动通过 BoosAPI 调用 Comfy Cloud 执行工作流。
**Architecture:** 新增 `/comfy-cloud/{prompt,history,view}` 端点,内部翻译成 `cloud.comfy.org/api/*` + `X-API-Key`。Comfy Cloud 的 base_url + key 从站点管理(`platform='comfy_cloud'` 的 active 站点)读取。纯翻译函数单测,wiremock 集成测。
**Tech Stack:** Rust (axum, reqwest, sqlx, serde_json, wiremock, tokio)。
> **构建/测试:** `cargo build -j 2`、`cargo test -p boosapi --lib comfy_cloud`。分支 `feat/comfy-cloud-adapter`。
---
## 文件结构
- Create `src/services/comfy_cloud/mod.rs` — 模块导出。
- Create `src/services/comfy_cloud/site.rs` — `resolve_comfy_cloud_site` 读站点配置。
- Create `src/services/comfy_cloud/translate.rs` — `comfy_cloud_job_to_history` 纯翻译函数。
- Create `src/routes/comfy_cloud_adapter.rs` — 三个适配端点 + `routes()`。
- Modify `src/services/mod.rs` — 加 `pub mod comfy_cloud;`。
- Modify `src/routes/mod.rs` — 加 `mod comfy_cloud_adapter;` + `.merge(...)`。
- Modify `src/web/pages/Sites.tsx` — `SITE_PLATFORM_OPTIONS` 加 `comfy_cloud` 选项。
---
<!-- TASKS -->
## Task 1: 站点配置读取 `resolve_comfy_cloud_site`
**Files:**
- Create: `src/services/comfy_cloud/mod.rs`
- Create: `src/services/comfy_cloud/site.rs`
- Modify: `src/services/mod.rs`
- [ ] **Step 1: `src/services/mod.rs` 末尾加模块声明**
```rust
pub mod comfy_cloud;
```
- [ ] **Step 2: 写 `src/services/comfy_cloud/mod.rs`**
```rust
//! Comfy Cloud 工作流执行适配器 service 层。
pub mod site;
pub mod translate;
```
- [ ] **Step 3: 写失败测试 `src/services/comfy_cloud/site.rs`**
```rust
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
/// 解析 Comfy Cloud 站点配置,返回 (base_url, api_key)。
/// 取 platform='comfy_cloud' 且 status='active' 的站点。
pub async fn resolve_comfy_cloud_site(db: &SqlitePool) -> AppResult<(String, String)> {
let row: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT url, api_key FROM sites
WHERE platform = 'comfy_cloud' AND status = 'active'
ORDER BY COALESCE(sort_order, 0), id LIMIT 1",
)
.fetch_optional(db)
.await?;
let (url, api_key) = row.ok_or_else(|| {
AppError::ServiceUnavailable("未配置 Comfy Cloud 站点(站点管理添加 comfy_cloud 平台)".into())
})?;
let api_key = api_key
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.ok_or_else(|| AppError::ServiceUnavailable("Comfy Cloud 站点缺少 API Key".into()))?;
let base_url = url.trim().trim_end_matches('/').to_string();
Ok((base_url, api_key))
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
async fn pool() -> SqlitePool {
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
sqlx::query("CREATE TABLE sites (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, api_key TEXT, sort_order INTEGER)")
.execute(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn resolves_active_comfy_cloud_site() {
let pool = pool().await;
sqlx::query("INSERT INTO sites (name,url,platform,status,api_key) VALUES ('cc','https://cloud.comfy.org/','comfy_cloud','active','key-123')")
.execute(&pool).await.unwrap();
let (url, key) = resolve_comfy_cloud_site(&pool).await.unwrap();
assert_eq!(url, "https://cloud.comfy.org");
assert_eq!(key, "key-123");
}
#[tokio::test]
async fn errors_when_not_configured() {
let pool = pool().await;
let r = resolve_comfy_cloud_site(&pool).await;
assert!(matches!(r, Err(AppError::ServiceUnavailable(_))));
}
}
```
- [ ] **Step 4: 运行测试,确认失败**
Run: `cargo test -p boosapi --lib services::comfy_cloud::site 2>&1 | tail -15`
Expected: FAIL —— 模块未挂载/函数未定义(编译错误)。
- [ ] **Step 5: 实现**
Step 3 的函数体即实现(测试与实现同文件)。确认 `src/services/comfy_cloud/mod.rs` 已 `pub mod site;`。
- [ ] **Step 6: 运行测试,确认通过**
Run: `cargo test -p boosapi --lib services::comfy_cloud::site 2>&1 | tail -10`
Expected: PASS(2 个测试)。
- [ ] **Step 7: Commit**
```bash
git add src/services/mod.rs src/services/comfy_cloud/mod.rs src/services/comfy_cloud/site.rs
git commit -m "feat(comfy-cloud): 站点配置读取 resolve_comfy_cloud_site"
```
<!-- T2 -->
## Task 2: 翻译函数 `comfy_cloud_job_to_history`
**Files:**
- Create: `src/services/comfy_cloud/translate.rs`
- [ ] **Step 1: 写失败测试 `src/services/comfy_cloud/translate.rs`**
```rust
use serde_json::{json, Value};
/// 把 Comfy Cloud 的 job status(+ 完成时的 jobs detail outputs)翻译成
/// ComfyUI 原生 /history/{id} 的形状。
///
/// - status pending/in_progress → 返回空对象 {}(comfykit 据此继续轮询)
/// - status completed → { "{id}": { "status": {completed:true, status_str:"success"}, "outputs": {...} } }
/// - status failed/cancelled → { "{id}": { "status": {completed:false, status_str:"error"} } }
pub fn comfy_cloud_job_to_history(
prompt_id: &str,
status: &str,
outputs: Option<&Value>,
) -> Value {
match status {
"completed" => json!({
prompt_id: {
"status": { "completed": true, "status_str": "success" },
"outputs": outputs.cloned().unwrap_or_else(|| json!({})),
}
}),
"failed" | "cancelled" => json!({
prompt_id: {
"status": { "completed": false, "status_str": "error" },
"outputs": {},
}
}),
_ => json!({}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_progress_returns_empty() {
assert_eq!(comfy_cloud_job_to_history("p1", "in_progress", None), json!({}));
assert_eq!(comfy_cloud_job_to_history("p1", "pending", None), json!({}));
}
#[test]
fn completed_wraps_outputs() {
let outputs = json!({ "9": { "images": [{ "filename": "a.png", "subfolder": "", "type": "output" }] } });
let h = comfy_cloud_job_to_history("p1", "completed", Some(&outputs));
assert_eq!(h["p1"]["status"]["completed"], true);
assert_eq!(h["p1"]["outputs"]["9"]["images"][0]["filename"], "a.png");
}
#[test]
fn failed_marks_error() {
let h = comfy_cloud_job_to_history("p1", "failed", None);
assert_eq!(h["p1"]["status"]["status_str"], "error");
assert_eq!(h["p1"]["status"]["completed"], false);
}
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `cargo test -p boosapi --lib services::comfy_cloud::translate 2>&1 | tail -15`
Expected: FAIL —— 模块未挂载/函数未定义。
- [ ] **Step 3: 实现**
Step 1 的函数体即实现。确认 `mod.rs` 已 `pub mod translate;`(Task 1 Step 2 已加)。
- [ ] **Step 4: 运行测试,确认通过**
Run: `cargo test -p boosapi --lib services::comfy_cloud::translate 2>&1 | tail -10`
Expected: PASS(3 个测试)。
- [ ] **Step 5: Commit**
```bash
git add src/services/comfy_cloud/translate.rs
git commit -m "feat(comfy-cloud): job→ComfyUI history 翻译函数"
```
<!-- T3 -->
## Task 3: 适配端点骨架 + `POST /comfy-cloud/prompt`
**Files:**
- Create: `src/routes/comfy_cloud_adapter.rs`
- [ ] **Step 1: 写骨架 + 测试 `src/routes/comfy_cloud_adapter.rs`**
```rust
use crate::error::{AppError, AppResult};
use crate::services::comfy_cloud::site::resolve_comfy_cloud_site;
use crate::services::comfy_cloud::translate::comfy_cloud_job_to_history;
use crate::AppState;
use axum::{
body::Body,
extract::{Path, RawQuery, State},
http::{header, StatusCode},
response::Response,
routing::{get, post},
Json, Router,
};
use serde_json::Value;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/comfy-cloud/prompt", post(submit_prompt))
.route("/comfy-cloud/history/{prompt_id}", get(get_history))
.route("/comfy-cloud/view", get(get_view))
}
fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
async fn submit_prompt(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let (base_url, api_key) = resolve_comfy_cloud_site(&state.db).await?;
let resp = client()
.post(format!("{base_url}/api/prompt"))
.header("X-API-Key", api_key)
.json(&body)
.send()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
let status = resp.status();
let text = resp.text().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
if !status.is_success() {
return Err(AppError::UpstreamError(format!("Comfy Cloud prompt [{status}]: {text}")));
}
let value: Value = serde_json::from_str(&text).unwrap_or_else(|_| serde_json::json!({ "body": text }));
Ok(Json(value))
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header as match_header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use sqlx::sqlite::SqlitePoolOptions;
async fn db_with_site(base_url: &str) -> sqlx::SqlitePool {
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
sqlx::query("CREATE TABLE sites (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, api_key TEXT, sort_order INTEGER)")
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO sites (name,url,platform,status,api_key) VALUES ('cc',?,'comfy_cloud','active','key-123')")
.bind(base_url).execute(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn submit_forwards_with_api_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/prompt"))
.and(match_header("X-API-Key", "key-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "prompt_id": "p1" })))
.mount(&server).await;
let pool = db_with_site(&server.uri()).await;
let (base_url, api_key) = resolve_comfy_cloud_site(&pool).await.unwrap();
// 直接验证转发契约(handler 依赖 AppState,这里测底层契约)
let resp = client().post(format!("{base_url}/api/prompt"))
.header("X-API-Key", api_key)
.json(&serde_json::json!({ "prompt": {} }))
.send().await.unwrap();
let v: Value = resp.json().await.unwrap();
assert_eq!(v["prompt_id"], "p1");
}
}
```
> 注:`submit_prompt` handler 需 `AppState`,完整 handler 走 Task 6 联调验证;本 task 的
> wiremock 测试验证「X-API-Key 注入 + `/api/prompt` 路径 + prompt_id 透传」这一核心契约。
- [ ] **Step 2: 运行测试,确认失败**
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -15`
Expected: FAIL —— 模块未挂载(`get_history`/`get_view` 未定义,编译错误)。
> 因 `routes()` 引用了 Task 4/5 的 `get_history`/`get_view`,本 task 先写它们的空桩
> 让编译过(Step 3),Task 4/5 再填实现。
- [ ] **Step 3: 加 `get_history`/`get_view` 空桩(接在 `submit_prompt` 之后,让编译通过)**
```rust
async fn get_history(
State(state): State<AppState>,
Path(prompt_id): Path<String>,
) -> AppResult<Json<Value>> {
let _ = (&state, &prompt_id);
Ok(Json(serde_json::json!({})))
}
async fn get_view(
State(state): State<AppState>,
RawQuery(query): RawQuery,
) -> AppResult<Response> {
let _ = (&state, &query);
Ok(Response::builder().status(StatusCode::NOT_IMPLEMENTED).body(Body::empty())
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?)
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -10`
Expected: PASS(1 个测试)。
> 此时 `routes::comfy_cloud_adapter` 模块还没挂进 `routes/mod.rs`,测试是模块内单测,
> 不需要挂载即可跑。挂载在 Task 6。
- [ ] **Step 5: Commit**
```bash
git add src/routes/comfy_cloud_adapter.rs
git commit -m "feat(comfy-cloud): 适配端点骨架 + POST /prompt 转发"
```
<!-- T4 -->
## Task 4: `GET /comfy-cloud/history/{id}` 轮询翻译
**Files:**
- Modify: `src/routes/comfy_cloud_adapter.rs`
- [ ] **Step 1: 写失败测试(加到测试模块内)**
```rust
#[tokio::test]
async fn history_in_progress_then_completed() {
let server = MockServer::start().await;
// 状态查询:completed
Mock::given(method("GET"))
.and(path("/api/job/p1/status"))
.and(match_header("X-API-Key", "key-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "completed" })))
.mount(&server).await;
// 详情:outputs
Mock::given(method("GET"))
.and(path("/api/jobs/p1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outputs": { "9": { "images": [{ "filename": "a.png", "subfolder": "", "type": "output" }] } }
})))
.mount(&server).await;
let pool = db_with_site(&server.uri()).await;
let history = fetch_history(&pool, "p1").await.unwrap();
assert_eq!(history["p1"]["status"]["completed"], true);
assert_eq!(history["p1"]["outputs"]["9"]["images"][0]["filename"], "a.png");
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter::tests::history 2>&1 | tail -15`
Expected: FAIL —— `fetch_history` 未定义。
- [ ] **Step 3: 实现 `fetch_history` + 替换 `get_history` 空桩**
把 Task 3 的 `get_history` 空桩替换为:
```rust
async fn fetch_history(db: &sqlx::SqlitePool, prompt_id: &str) -> AppResult<Value> {
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
let http = client();
let status_resp = http
.get(format!("{base_url}/api/job/{prompt_id}/status"))
.header("X-API-Key", &api_key)
.send().await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
if !status_resp.status().is_success() {
let st = status_resp.status();
let body = status_resp.text().await.unwrap_or_default();
return Err(AppError::UpstreamError(format!("Comfy Cloud status [{st}]: {body}")));
}
let status_json: Value = status_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
let status = status_json.get("status").and_then(Value::as_str).unwrap_or("in_progress");
if status != "completed" {
return Ok(comfy_cloud_job_to_history(prompt_id, status, None));
}
let detail_resp = http
.get(format!("{base_url}/api/jobs/{prompt_id}"))
.header("X-API-Key", &api_key)
.send().await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
let detail_json: Value = detail_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
let outputs = detail_json.get("outputs");
Ok(comfy_cloud_job_to_history(prompt_id, status, outputs))
}
async fn get_history(
State(state): State<AppState>,
Path(prompt_id): Path<String>,
) -> AppResult<Json<Value>> {
Ok(Json(fetch_history(&state.db, &prompt_id).await?))
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -10`
Expected: PASS(submit + history 共 2 个测试)。
- [ ] **Step 5: Commit**
```bash
git add src/routes/comfy_cloud_adapter.rs
git commit -m "feat(comfy-cloud): /history 轮询翻译(status+jobs→history)"
```
<!-- T5 -->
## Task 5: `GET /comfy-cloud/view` 产出文件透传
**Files:**
- Modify: `src/routes/comfy_cloud_adapter.rs`
- [ ] **Step 1: 写失败测试(加到测试模块内)**
```rust
#[tokio::test]
async fn view_proxies_file_bytes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/view"))
.and(match_header("X-API-Key", "key-123"))
.respond_with(ResponseTemplate::new(200)
.insert_header("content-type", "image/png")
.set_body_bytes(b"PNGDATA".to_vec()))
.mount(&server).await;
let pool = db_with_site(&server.uri()).await;
let (mime, bytes) = fetch_view(&pool, Some("filename=a.png&type=output")).await.unwrap();
assert_eq!(mime, "image/png");
assert_eq!(bytes, b"PNGDATA");
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter::tests::view 2>&1 | tail -15`
Expected: FAIL —— `fetch_view` 未定义。
- [ ] **Step 3: 实现 `fetch_view` + 替换 `get_view` 空桩**
把 Task 3 的 `get_view` 空桩替换为:
```rust
async fn fetch_view(db: &sqlx::SqlitePool, query: Option<&str>) -> AppResult<(String, Vec<u8>)> {
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
let q = query.unwrap_or("");
let url = if q.is_empty() { format!("{base_url}/api/view") } else { format!("{base_url}/api/view?{q}") };
let resp = client()
.get(url)
.header("X-API-Key", api_key)
.send().await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
if !resp.status().is_success() {
let st = resp.status();
return Err(AppError::UpstreamError(format!("Comfy Cloud view [{st}]")));
}
let mime = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = resp.bytes().await.map_err(|e| AppError::UpstreamError(e.to_string()))?.to_vec();
Ok((mime, bytes))
}
async fn get_view(
State(state): State<AppState>,
RawQuery(query): RawQuery,
) -> AppResult<Response> {
let (mime, bytes) = fetch_view(&state.db, query.as_deref()).await?;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.body(Body::from(bytes))
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
}
```
> reqwest 默认跟随 302,所以 Comfy Cloud `/api/view` 的签名 URL 跳转会被自动跟随,
> 最终拿到文件字节。无需手动处理 Location。
- [ ] **Step 4: 运行测试,确认通过**
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -10`
Expected: PASS(submit + history + view 共 3 个测试)。
- [ ] **Step 5: Commit**
```bash
git add src/routes/comfy_cloud_adapter.rs
git commit -m "feat(comfy-cloud): /view 产出文件透传"
```
<!-- T6 -->
## Task 6: 挂载路由 + 前端平台选项 + 全量验证
**Files:**
- Modify: `src/routes/mod.rs`
- Modify: `src/web/pages/Sites.tsx`
- [ ] **Step 1: `src/routes/mod.rs` 顶部模块声明区加**
```rust
mod comfy_cloud_adapter;
```
- [ ] **Step 2: `build_router` 的 merge 链里加(`.merge(picture_agent::routes())` 附近)**
```rust
.merge(comfy_cloud_adapter::routes())
```
- [ ] **Step 3: 前端 `SITE_PLATFORM_OPTIONS` 加 comfy_cloud 选项**
在 `src/web/pages/Sites.tsx` 的 `SITE_PLATFORM_OPTIONS` 数组(约 line 700-710,
`{ value: 'cliproxyapi', ... }` 附近)加一项:
```tsx
{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API(数字人/图片视频)' },
```
- [ ] **Step 4: 后端全量构建 + comfy_cloud 测试**
Run: `cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib comfy_cloud 2>&1 | command grep "test result"`
Expected: `Finished`;comfy_cloud service + routes 测试全 PASS。
- [ ] **Step 5: 前端 typecheck**
Run: `npm run typecheck:web 2>&1 | tail -5`
Expected: 无错误。
- [ ] **Step 6: Commit**
```bash
git add src/routes/mod.rs src/web/pages/Sites.tsx
git commit -m "feat(comfy-cloud): 挂载适配路由 + 站点管理 comfy_cloud 平台选项"
```
---
## Task 7: 真实联调(手动验证,非自动测试)
> 这一步需要运行中的 BoosAPI + Pixelle,属手动验证。前 6 个 task 完成且 push 后执行。
- [ ] **Step 1: 站点管理添加 Comfy Cloud 站点**
在管理控制台「站点管理」新增站点:平台选 `Comfy Cloud`,URL 填 `https://cloud.comfy.org`,
API Key 填有效的 Comfy Cloud key(`X-API-Key`),状态 active。
- [ ] **Step 2: 改 Pixelle config.yaml 的 comfyui_url**
把 `/opt/Pixelle-Video/config.yaml` 的 `comfyui.comfyui_url` 改为
`http://<boosapi-host>:<port>/comfy-cloud`(指向 BoosAPI 适配端点),
然后 `docker compose restart api`(在 `/opt/Pixelle-Video`)。
- [ ] **Step 3: 跑一次 selfhost 视频生成**
从前端触发数字人/图片 selfhost 工作流生成视频,确认:
- 不再出现 `401 AuthenticationRequired`。
- 任务能提交(拿到 prompt_id)、轮询到 completed、取回产出文件。
- [ ] **Step 4: 失败排查指引(若仍报错)**
- 仍 401:检查站点 api_key 是否为有效 Comfy Cloud key(`curl -H "X-API-Key: KEY" https://cloud.comfy.org/api/user` 应回 200)。
- 404/路径错:确认 Pixelle comfyui_url 末尾无多余 `/`,BoosAPI 适配端点前缀为 `/comfy-cloud`。
- 取不到产出:看 BoosAPI 日志里 `/api/jobs/{id}` 的响应 outputs 结构,必要时调 translate 映射。
---
## 完成标准
- 后端:`resolve_comfy_cloud_site` + `comfy_cloud_job_to_history` + 三个适配端点
(`/comfy-cloud/{prompt,history,view}`)实现并测试通过,`cargo build -j 2` 干净。
- 前端:站点管理可选 `comfy_cloud` 平台,`npm run typecheck:web` 干净。
- 联调:Pixelle 指向 BoosAPI 适配端点后,selfhost 视频生成不再 401 且能出片。
- 全部提交在 `feat/comfy-cloud-adapter` 分支。
@@ -1,241 +0,0 @@
# 数字人接入 Comfy Cloud 视频工作流(完整视频入口)实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 给 BoosAPI 数字人加「视频工作流」入口,让用户选 `comfy/video_digital_ltx2.json` 触发「肖像图+旁白音频→LTX2.3 说话视频」,贯穿前端→BoosAPI 后端→Pixelle 三层。
**Architecture:** 前端 `DigitalHuman.tsx` 加可选「视频工作流」选择器(列 `video_*`),提交 `api_video_workflow`;BoosAPI `digital_human.rs` 的 `pixelle_request_body` 透传该字段;Pixelle `asset_based.py` 对 `comfy/` 视频工作流也注入 `audio_path`。视频入口默认空=不启用,保持现有「图片+HTML 合成」向后兼容。
**Tech Stack:** React/TS(BoosAPI 前端)、Rust/axum(BoosAPI 后端)、Python(Pixelle,容器内)。
> **环境:** BoosAPI 仓库 `/root/metapi`,前端 `src/web`,后端 `src/routes`。Pixelle 在 Docker 容器 `pixelle-video-api`,源码 `/app/pixelle_video`(镜像内置,改后 `docker compose restart api`,备份到 `/opt/Pixelle-Video/patches/`)。构建:前端 `npm run typecheck:web`,后端 `cargo build -j 2`(-j 2 防 OOM)。分支 `feat/comfy-cloud-adapter`。
> **前置(已完成):** comfy-cloud 适配器、`comfy/video_digital_ltx2.json`(probe 通过)、media.py 转发 image_path。
---
## 文件结构
- Modify `src/web/pages/DigitalHuman.tsx` — 加 `apiVideoWorkflow` state + 「视频工作流」选择器 + payload 字段 + draft 持久化。
- Modify `src/routes/digital_human.rs` — `pixelle_request_body` 转发 `api_video_workflow`。
- Modify(容器内)`/app/pixelle_video/pipelines/asset_based.py` — comfy 视频工作流注入 audio_path。
---
<!-- TASKS -->
## Task 1: 前端加「视频工作流」选择器 + 提交字段
**Files:**
- Modify: `src/web/pages/DigitalHuman.tsx`
- [ ] **Step 1: `DigitalHumanDraft` type 加字段(~line 34 附近,imageWorkflow 旁)**
找到 `imageWorkflow?: string;`(约 line 34),其后加一行:
```ts
apiVideoWorkflow?: string;
```
- [ ] **Step 2: 加 state(~line 370,imageWorkflow useState 旁)**
找到 `const [imageWorkflow, setImageWorkflow] = useState(draftRef.current.imageWorkflow || '');`(约 line 370),其后加:
```ts
const [apiVideoWorkflow, setApiVideoWorkflow] = useState(draftRef.current.apiVideoWorkflow || '');
```
- [ ] **Step 3: draft 持久化加字段(~line 462 写入 + ~line 480 依赖数组)**
在 `writeDigitalHumanDraft({...})` 对象里(约 line 462),`imageWorkflow,` 后加 `apiVideoWorkflow,`;
在该 useEffect 的依赖数组里(约 line 480),`imageWorkflow,` 后同样加 `apiVideoWorkflow,`。
- [ ] **Step 4: 提交 payload 加字段(~line 570 payload 对象)**
找到 payload 对象里的 `media_workflow: imageWorkflow || undefined,`(约 line 575),其后加:
```ts
api_video_workflow: apiVideoWorkflow || undefined,
```
- [ ] **Step 5: 加「视频工作流」选择器 UI(图片工作流 select 之后,~line 1012)**
找到「图片工作流」那个 `<label className="digital-human-field">...select...</label>` 块结束处
(约 line 1012,`</label>` 后),插入新选择器:
```tsx
<label className="digital-human-field">
<span>视频工作流(可选,生成说话视频)</span>
<select value={apiVideoWorkflow} onChange={(event) => setApiVideoWorkflow(event.target.value)}>
<option value="">不生成视频(仅图片合成)</option>
{mediaWorkflows
.filter((wf) => wf.name.toLowerCase().includes('video'))
.map((wf) => (
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
))}
</select>
</label>
```
- [ ] **Step 6: typecheck**
Run: `cd /root/metapi && npm run typecheck:web 2>&1 | tail -5`
Expected: 无 TS 错误。
- [ ] **Step 7: Commit**
```bash
cd /root/metapi && git add src/web/pages/DigitalHuman.tsx
git commit -m "feat(digital-human): 前端加视频工作流选择器(api_video_workflow)
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T2 -->
## Task 2: BoosAPI 后端转发 api_video_workflow 给 Pixelle
**Files:**
- Modify: `src/routes/digital_human.rs`
- [ ] **Step 1: 在 pixelle_request_body 加转发(~line 2077,tts_workflow 块之后)**
找到 `pixelle_request_body` 里转发 tts_workflow 的块(约 line 2073-2078):
```rust
if let Some(value) = optional_string(body, &["ttsWorkflow", "tts_workflow"])
.or_else(|| default_pixelle_tts_workflow(state, backend_provider))
{
request_body["tts_workflow"] = json!(value);
}
```
在它**之后**插入:
```rust
if let Some(value) = optional_string(body, &["apiVideoWorkflow", "api_video_workflow"]) {
request_body["api_video_workflow"] = json!(value);
}
```
- [ ] **Step 2: 构建确认编译**
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
Expected: `Finished`。
- [ ] **Step 3: 确认 optional_string 接受多 key(它已在文件内大量使用,签名兼容)**
Run: `cd /root/metapi && rtk proxy grep -n "fn optional_string" src/routes/digital_human.rs | head`
Expected: 找到定义,签名为 `(body, keys: &[&str])`(与 Step 1 用法一致)。若签名不同,按实际调整调用形式。
- [ ] **Step 4: Commit**
```bash
cd /root/metapi && git add src/routes/digital_human.rs
git commit -m "feat(digital-human): 后端转发 api_video_workflow 给 Pixelle
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T3 -->
## Task 3: Pixelle 给 comfy 视频工作流注入 audio_path
**Files:**
- Modify(容器内):`/app/pixelle_video/pipelines/asset_based.py`
- [ ] **Step 1: 定位音频注入块(~line 703)**
Run: `rtk proxy docker exec pixelle-video-api sh -lc "grep -n 'audio_driven_i2v' /app/pixelle_video/pipelines/asset_based.py"`
Expected: 命中 `if "audio_driven_i2v" in adapter_abilities:` 那行(约 703)。
- [ ] **Step 2: 在该判断旁加 comfy 前缀分支(用 python 精确改写)**
```bash
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
p='/app/pixelle_video/pipelines/asset_based.py'
src=open(p).read()
anchor=' if \"audio_driven_i2v\" in adapter_abilities:\n api_video_params[\"audio_path\"] = frame.audio_path'
assert anchor in src, 'anchor not found'
add=anchor + '''
elif isinstance(api_video_workflow, str) and api_video_workflow.startswith(\"comfy/\"):
api_video_params[\"audio_path\"] = frame.audio_path'''
if 'api_video_workflow.startswith(\"comfy/\")' in src:
print('already patched')
else:
src=src.replace(anchor, add, 1)
open(p,'w').write(src)
print('patched')
"
```
Expected: `patched`(或 `already patched`)。
- [ ] **Step 3: 语法校验 + 重启 + 备份**
```bash
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "import ast; ast.parse(open('/app/pixelle_video/pipelines/asset_based.py').read()); print('syntax OK')"
cd /opt/Pixelle-Video && rtk proxy docker compose restart api && sleep 7 && rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
mkdir -p /opt/Pixelle-Video/patches && rtk proxy docker cp pixelle-video-api:/app/pixelle_video/pipelines/asset_based.py /opt/Pixelle-Video/patches/asset_based.py.patched
```
Expected: `syntax OK`;容器 healthy;备份文件存在。
- [ ] **Step 4: 确认改动重启后仍在**
Run: `rtk proxy docker exec pixelle-video-api sh -lc "grep -c 'api_video_workflow.startswith(\"comfy/\")' /app/pixelle_video/pipelines/asset_based.py"`
Expected: `1`。无 git commit(Pixelle 非本仓库)。
<!-- T4 -->
## Task 4: 部署 BoosAPI + 端到端联调
**Files:** 无(部署 + 运行时验证)
- [ ] **Step 1: 重新构建并重启 BoosAPI(含前端改动)**
前端改了需重新 build 前端资源,后端改了需重新编译。用项目脚本:
```bash
cd /root/metapi && ./build-and-run.sh --fast-release 2>&1 | tail -15
```
> 若只想快速验证后端逻辑、前端单独 build,可 `npm run build:web` + `./build-and-run.sh --backend-only`。
> Expected: 构建成功,BoosAPI 起在 :4000。注意这是动线上服务,确认当前无关键流量再重启。
- [ ] **Step 2: 确认数字人 UI 出现「视频工作流」选择器**
打开管理控制台数字人页面,确认新增「视频工作流(可选)」下拉,选项含
`video_digital_ltx2`(comfy)+ `video_wan2.1_fusionx`(comfy)等 video_* 工作流。
- [ ] **Step 3: 发起数字人任务,视频工作流选 `comfy/video_digital_ltx2.json`**
给肖像图 + 文本 + 选 TTS,视频工作流选 `comfy/video_digital_ltx2.json`,提交。
- [ ] **Step 4: 监控流转(BoosAPI 日志 + Comfy Cloud job)**
```bash
cd /root/metapi && rtk proxy sqlite3 boosapi.db "SELECT id, created_at, client_family, http_status, substr(error_message,1,80) FROM proxy_logs ORDER BY id DESC LIMIT 5;"
```
Expected: 新日志(id 增长),数字人任务推进;Comfy Cloud 出现 LTX2.3 job;最终出说话视频。
- [ ] **Step 5: 出错归因**
- LoadImage 仍用默认图 → 查 media.py image_path 转发(Task 已完成,确认 `grep image_path /app/pixelle_video/services/media.py`)。
- LoadAudio 无音频 → 查 Task 3 注入(`grep comfy/ /app/pixelle_video/pipelines/asset_based.py`)+ workflow 标记 `$~audio_path!`。
- `api_video_workflow` 没传到 → 查 Task 2 后端转发 + Task 1 前端 payload 字段名(`api_video_workflow`)。
- 节点/模型错 → 不应发生(probe 已过);若发生看 Comfy Cloud 真实回包。
- [ ] **Step 6: 成功标准**
数字人任务选 comfy/video_digital_ltx2.json 后,Comfy Cloud 出真实 job,注入了
image_path+audio_path+prompt,返回带音频说话视频,数字人成片。
---
## 完成标准
- 前端:数字人页有「视频工作流」可选下拉,列 video_* 工作流,typecheck 干净。
- 后端:`api_video_workflow` 透传给 Pixelle,`cargo build -j 2` 通过。
- Pixelle:comfy 视频工作流注入 audio_path,重启生效,备份 patches/。
- 端到端:选 comfy/video_digital_ltx2.json 出说话视频。
- 向后兼容:不选视频工作流时,现有「图片+HTML 合成」行为不变。
## 已知风险 / 备注
- Pixelle asset_based.py 改动在镜像内置层,重建镜像会丢,已备份 patches/,长期应固化 Dockerfile。
- Task 4 动线上服务(重启 BoosAPI),确认时机。
- 单人肖像;多人不在范围。
- LTX2.3 执行时长/分辨率等真实生成参数,若出片质量/超时问题属调参,非本计划的接线问题。
@@ -1,335 +0,0 @@
# LTX2.3 数字人工作流(Comfy Cloud)实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 Pixelle 数字人(说话头像)走 Comfy Cloud:把官方 LTX2.3 id_lora 模板做成 `comfy/digital_ltx2.json` 工作流,补上 Pixelle media.py 的 image_path 转发缺口,经已建好的 comfy-cloud 适配器执行。
**Architecture:** 工作流文件落 Pixelle 宿主 `data/workflows/comfy/`(经 `./data` 挂载),用 comfykit 的 `_meta.title` 标记(`$~image_path!`/`$~audio_path!`/`$prompt!`)注入肖像/音频/脚本;media.py 非 api 分支补 image_path 进 workflow_params;comfy/ 通道经 `comfyui_url`(=BoosAPI `/comfy-cloud` 适配器)打到 Comfy Cloud。
**Tech Stack:** Python (Pixelle/comfykit), ComfyUI API-format JSON, Comfy Cloud API, Docker。
> **环境约定:** Pixelle 跑在 Docker 容器 `pixelle-video-api`,代码 `/app/pixelle_video/`,工作流 `/app/data/workflows/`(宿主 `/opt/Pixelle-Video/data/workflows/`,bind mount,改宿主即生效)。容器内 python:`/app/.venv/bin/python`。BoosAPI 适配端点:容器内经 `http://172.17.0.1:4000/comfy-cloud`,宿主经 `http://127.0.0.1:4000/comfy-cloud`。改 media.py 后需 `docker compose restart api`。
> **硬依赖(Task 1 阻塞全局):** 需用户从 Comfy Cloud 网页导出 `video_ltx2_3_id_lora` 的 **API 格式** JSON 到 `/tmp/ltx2_api.json`。服务端无法代取。
---
## 文件结构
- 用户提供:`/tmp/ltx2_api.json` —— Comfy Cloud 导出的 API 格式工作流(子图已展开)。
- Create `/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json` —— 改造后的数字人工作流(加标记、删 RecordAudio)。
- Modify(容器内)`/app/pixelle_video/services/media.py` —— 非 api 分支把 image_path 加入 workflow_params;同步宿主持久化方案。
- 临时:`/tmp/digital_ltx2_probe.json` —— 校验探测用 payload。
---
<!-- TASKS -->
## Task 1: 获取并校验 API 格式导出(硬依赖)
**Files:**
- 用户提供:`/tmp/ltx2_api.json`
- [ ] **Step 1: 确认导出文件存在且是 API 格式**
用户在 Comfy Cloud 网页打开 `video_ltx2_3_id_lora`(或 share `71f221d28df8`)→ Workflow → Export (API Format) → 放到 `/tmp/ltx2_api.json`。
Run: `rtk proxy docker exec pixelle-video-api sh -lc "ls -la /tmp/ltx2_api.json 2>/dev/null || echo MISSING"`
注意:文件在宿主还是容器?用户可能放宿主 `/tmp`。两处都查:
Run(宿主): `ls -la /tmp/ltx2_api.json 2>/dev/null || echo HOST_MISSING`
Expected: 文件存在(任一处)。若两处都 MISSING → BLOCKED,等用户提供。
- [ ] **Step 2: 校验是 API 格式(不是 UI 格式)**
API 格式是 `{节点id: {class_type, inputs, ...}}`,无顶层 `nodes` 数组。
Run:
```bash
python3 -c "
import json
wf=json.load(open('/tmp/ltx2_api.json'))
is_ui = isinstance(wf,dict) and 'nodes' in wf
print('UI格式(需重导)' if is_ui else 'API格式 OK')
if not is_ui:
print('节点数:', len(wf))
has_sub = any('subgraph' in str(v.get('class_type','')).lower() for v in wf.values() if isinstance(v,dict))
print('含未展开子图?', has_sub)
"
```
Expected: `API格式 OK`,节点数 >10,无未展开子图。若是 UI 格式或含子图 → 让用户用 **API Format** 重新导出(普通 Export 是 UI 格式)。
- [ ] **Step 3: 列出节点类型 + 模型引用,核对 Comfy Cloud 兼容**
Run:
```bash
python3 -c "
import json
wf=json.load(open('/tmp/ltx2_api.json'))
from collections import Counter
c=Counter(v.get('class_type') for v in wf.values() if isinstance(v,dict))
print('节点类型:')
for t,n in c.most_common(): print(f' {n}x {t}')
print('--- 模型引用 ---')
for nid,nd in wf.items():
if not isinstance(nd,dict): continue
for k,v in (nd.get('inputs') or {}).items():
if isinstance(v,str) and v.endswith('.safetensors'):
print(f' {nid}.{k} = {v}')
"
```
Expected: 看到 LTX2.3 节点 + 模型文件名。记录下来,Task 2 核对它们在 Comfy Cloud 池里(之前已验证 ltx-2.3-* 系列在;若导出引用了不同文件名,需在此对照 `/tmp/cc_nodes.json` 的池子)。
- [ ] **Step 4: 无需提交(纯校验,无文件改动)**
本任务只确认输入就绪。无 commit。
<!-- T2 -->
## Task 2: 改造工作流(加标记 + 删 RecordAudio + 落 comfy/)
**Files:**
- Create: `/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json`
- [ ] **Step 1: 找到要标记的节点 id(LoadImage/LoadAudio/文本/RecordAudio)**
Run:
```bash
python3 -c "
import json
wf=json.load(open('/tmp/ltx2_api.json'))
for nid,nd in wf.items():
if not isinstance(nd,dict): continue
ct=nd.get('class_type','')
if ct in ('LoadImage','LoadAudio','RecordAudio','SaveVideo','PrimitiveStringMultiline','CLIPTextEncode'):
print(nid, ct, '| inputs:', json.dumps(nd.get('inputs',{}),ensure_ascii=False)[:100])
"
```
Expected: 看到 LoadImage、LoadAudio、RecordAudio、SaveVideo、文本节点各自的 id。记下:
- LoadImage 的 id(标 `$~image_path!`)
- LoadAudio 的 id(标 `$~audio_path!`)
- 文本提示节点 id(标 `$prompt!`)—— 优先 `PrimitiveStringMultiline`;若文本经 CLIPTextEncode 的 `text` 字段直接给,则标 CLIPTextEncode
- RecordAudio 的 id(删除)
- [ ] **Step 2: 写改造脚本生成 comfy/digital_ltx2.json**
> 把下面 `IMAGE_ID`/`AUDIO_ID`/`TEXT_ID`/`RECORD_ID` 替换为 Step 1 查到的实际 id。
> 文本节点字段名:若 PrimitiveStringMultiline 用 `value` 字段,标记写 `$prompt.value!`;
> 若 CLIPTextEncode 用 `text` 字段,写 `$prompt.text!`。按 Step 1 实际字段定。
```bash
python3 -c "
import json
wf=json.load(open('/tmp/ltx2_api.json'))
IMAGE_ID='REPLACE_IMAGE_ID'
AUDIO_ID='REPLACE_AUDIO_ID'
TEXT_ID='REPLACE_TEXT_ID'
TEXT_MARKER='\$prompt.value!' # 或 \$prompt.text! 视字段而定
RECORD_ID='REPLACE_RECORD_ID' # 若无 RecordAudio 留空字符串
def set_title(nid, marker):
nd=wf[nid]
nd.setdefault('_meta',{})['title']=marker
set_title(IMAGE_ID, '\$~image_path!')
set_title(AUDIO_ID, '\$~audio_path!')
set_title(TEXT_ID, TEXT_MARKER)
# 删 RecordAudio 并清理引用它的连线(把指向它的输入置空/删除)
if RECORD_ID and RECORD_ID in wf:
wf.pop(RECORD_ID)
for nid,nd in wf.items():
if not isinstance(nd,dict): continue
for k,v in list((nd.get('inputs') or {}).items()):
if isinstance(v,list) and len(v)==2 and str(v[0])==RECORD_ID:
# 该输入原本接 RecordAudio;数字人用 LoadAudio,删除这个悬空连接
del nd['inputs'][k]
import os
os.makedirs('/opt/Pixelle-Video/data/workflows/comfy', exist_ok=True)
json.dump(wf, open('/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json','w'), ensure_ascii=False, indent=2)
print('written, 节点数:', len(wf))
# 校验标记落位
for nid in [IMAGE_ID,AUDIO_ID,TEXT_ID]:
print(nid, '->', wf.get(nid,{}).get('_meta',{}).get('title'))
"
```
Expected: 写入成功,三个标记落位正确,RecordAudio 已删。
> ⚠️ RecordAudio 与 LoadAudio 的关系:数字人用 LoadAudio 输入旁白。若导出的图里音频源是 RecordAudio 而非 LoadAudio(录音节点直接接到音频处理),则不能简单删 RecordAudio——要把音频处理节点的输入改接到 LoadAudio。Step 1 查清音频实际从哪个节点流出;若是 RecordAudio 流出,本步改为:保留一个 LoadAudio(标 `$~audio_path!`)并把下游音频输入接到 LoadAudio 的输出。具体连接以 Step 1 的图为准。
- [ ] **Step 3: 校验生成的 JSON 合法 + comfykit 能解析出参数**
Run(容器内,用 comfykit 解析):
```bash
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
import json
from comfykit.comfyui.workflow_parser import WorkflowParser
wf=json.load(open('/app/data/workflows/comfy/digital_ltx2.json'))
p=WorkflowParser()
params=[]
for nid,nd in wf.items():
if not isinstance(nd,dict): continue
pl,ml,om=p.parse_node(nid,nd)
for x in pl: params.append((x.name,x.required,x.need_upload))
print('解析出的参数:', params)
"
```
Expected: 看到 `image_path`(upload=True)、`audio_path`(upload=True)、`prompt` 三个参数。
- [ ] **Step 4: Commit(工作流文件在 Pixelle 宿主目录,非 BoosAPI 仓库——仅记录到计划备注)**
工作流文件不在 git 仓库内。无 git commit;在此记录文件已落 `/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json`。
<!-- T3 -->
## Task 3: 补 media.py 转发 image_path 到 comfy 通道
**Files:**
- Modify(容器内):`/app/pixelle_video/services/media.py`
> 注:`pixelle_video` 源码**在镜像内置,不在挂载卷**。容器内改 `docker compose restart` 能留,
> 但 `docker compose down && up`/重建镜像会丢。持久化方案见 Step 4。
- [ ] **Step 1: 定位 workflow_params 构建处**
`media.py` 的 `__call__` 里,api/ 分支之后、`workflow_params = {"prompt": prompt}` 起的那段
(约 line 232)。`image_path` 是 `__call__` 的具名参数但没进 `workflow_params`。
Run: `rtk proxy docker exec pixelle-video-api sh -lc "grep -n 'workflow_params = {\"prompt\"' /app/pixelle_video/services/media.py"`
Expected: 定位到该行号(约 232)。
- [ ] **Step 2: 在 workflow_params 构建处加入 image_path**
在 `workflow_params = {"prompt": prompt}` 之后插入(用 sed 或 python 改文件)。注意这是容器内文件,
用 `docker exec ... python` 做精确插入最稳妥:
```bash
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
p='/app/pixelle_video/services/media.py'
src=open(p).read()
anchor='workflow_params = {\"prompt\": prompt}'
assert anchor in src, 'anchor not found'
add='''workflow_params = {\"prompt\": prompt}
if image_path is not None:
workflow_params[\"image_path\"] = image_path'''
if 'workflow_params[\"image_path\"]' in src:
print('already patched')
else:
src=src.replace(anchor, add, 1)
open(p,'w').write(src)
print('patched')
"
```
Expected: `patched`(或 `already patched`)。
- [ ] **Step 3: 校验语法 + 重启容器**
```bash
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "import ast; ast.parse(open('/app/pixelle_video/services/media.py').read()); print('syntax OK')"
cd /opt/Pixelle-Video && rtk proxy docker compose restart api && sleep 7 && rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
```
Expected: `syntax OK`;容器 `Up ... (healthy)`。
- [ ] **Step 4: 持久化方案(防镜像重建丢失)**
把改动备份到宿主,并记录到 Pixelle 部署说明:
```bash
rtk proxy docker cp pixelle-video-api:/app/pixelle_video/services/media.py /opt/Pixelle-Video/patches/media.py.patched 2>/dev/null || (mkdir -p /opt/Pixelle-Video/patches && rtk proxy docker cp pixelle-video-api:/app/pixelle_video/services/media.py /opt/Pixelle-Video/patches/media.py.patched)
echo "已备份改后 media.py 到 /opt/Pixelle-Video/patches/;重建镜像后需重新 docker cp 回 /app/pixelle_video/services/media.py 或固化进 Dockerfile"
```
Expected: 备份文件存在。无 git commit(Pixelle 非本仓库)。
<!-- T4 -->
## Task 4: 校验探测(确认 Comfy Cloud 接受工作流)
**Files:**
- 临时:`/tmp/digital_ltx2_probe.json`
- [ ] **Step 1: 用占位输入构建 probe payload**
注入标记的字段需要具体值才能提交校验。用占位文件名探测(校验在执行前,不真正读文件):
```bash
python3 -c "
import json
wf=json.load(open('/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json'))
# probe 直接提交节点图本身(标记字段已有默认值或占位);comfykit 注入是运行时的事,
# 这里验证 Comfy Cloud 是否接受这套节点+模型
json.dump({'prompt':wf}, open('/tmp/digital_ltx2_probe.json','w'), ensure_ascii=False)
print('probe payload bytes:', len(open('/tmp/digital_ltx2_probe.json').read()))
"
```
Expected: payload 生成。
> 注:工作流里 LoadImage/LoadAudio 的默认 widget 值(模板自带的示例文件名)可能在 Comfy Cloud
> 不存在,导致校验报"文件不存在"。这是**预期且可接受**的——它证明节点+模型校验已过,只差真实输入。
> 关注点是有没有 `unsupported node type` / 模型缺失类错误。
- [ ] **Step 2: 提交探测,读校验结果**
```bash
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:4000/comfy-cloud/prompt -H "Content-Type: application/json" --data @/tmp/digital_ltx2_probe.json 2>&1 | head -15
```
Expected 判读:
- `{"node_errors":{},"prompt_id":"..."}` HTTP 200 → 完美,全兼容。
- `400 ... unsupported node type 'X'` → 节点 X 不在 Comfy Cloud,需在工作流里替换/移除该节点(回 Task 2 处理)。
- `400 ... 文件/模型 不存在`(针对 LoadImage 示例图或某 .safetensors)→ 若是输入文件(LoadImage 默认图)属预期;若是 .safetensors 模型缺失,需换 Comfy Cloud 有的等价模型(对照 `/tmp/cc_nodes.json` 池)。
- [ ] **Step 3: 据结果迭代或放行**
- 全兼容 → 进 Task 5。
- 节点/模型问题 → 回 Task 2 调整工作流(换节点或模型名),重跑 Task 4。不要跳过。
<!-- T5 -->
## Task 5: 真实数字人任务联调
**Files:** 无(运行时验证)
- [ ] **Step 1: 在 Pixelle 前端发起数字人任务,视频/动画工作流选 `comfy/digital_ltx2.json`**
数字人流程的 image→video 动画步(`api_video_workflow` 或对应 UI 项)填/选 `comfy/digital_ltx2.json`。
触发一个任务(给个肖像图 + 文本)。
- [ ] **Step 2: 观察任务流转**
预期:LLM 脚本步 → 生成肖像图(comfy 图片工作流)+ TTS 音频 → digital_ltx2 动画步:
注入 image_path/audio_path/prompt → Comfy Cloud 建 job → 轮询 completed → 取回说话视频 → HTML 合成。
监控 BoosAPI 侧 proxy_logs:
```bash
rtk proxy sqlite3 boosapi.db "SELECT id, created_at, client_family, http_status, substr(error_message,1,80) FROM proxy_logs ORDER BY id DESC LIMIT 5;"
```
Expected: 新日志(id 增长),数字人任务最终 success / 出片。
- [ ] **Step 3: 出错则按错误类型归因**
- `unsupported node` / 模型缺失 → 回 Task 2/4。
- image_path 未注入(LoadImage 仍用默认图)→ 回 Task 3 确认 media.py 改动生效(`grep image_path /app/.../media.py`)+ Task 2 确认 `$~image_path!` 标记落位。
- 执行期失败(Comfy Cloud job failed)→ 真实生成问题(参数/分辨率/时长),据 job 错误调工作流默认值。
- [ ] **Step 4: 成功标准**
数字人任务在 Comfy Cloud 出现真实 job、跑完、返回带音频的说话视频,Pixelle 合成出片。
---
## 完成标准
- `comfy/digital_ltx2.json` 落地、comfykit 能解析出 image_path/audio_path/prompt 三参数。
- media.py 已转发 image_path 到 comfy 通道(并备份到宿主 patches/)。
- 校验探测确认 Comfy Cloud 接受工作流(节点+模型兼容)。
- 真实数字人任务经 comfy/Comfy Cloud 出片。
## 已知风险 / 备注
- 硬依赖用户导出 API JSON(Task 1);拿不到则全卡。
- media.py 改动在镜像内置层,重建镜像会丢,已备份到 `/opt/Pixelle-Video/patches/`,长期应固化进 Dockerfile/启动脚本。
- 工作流默认 widget 值(示例图/音频文件名)在 Comfy Cloud 不存在属正常,真实运行靠注入覆盖。
- 单人肖像;多人对话不在范围。
@@ -1,503 +0,0 @@
# Pixelle 连接凭证纳入 BoosAPI 管理 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 BoosAPI 连接管理页加「Pixelle 连接」卡片,管 runninghub_api_key / comfyui_url / comfyui_api_key 三个凭证,保存即写入 settings 表 + 同步 Pixelle config.yaml + 重启 pixelle 容器生效。
**Architecture:** 新增 service `pixelle_connection.rs`(读写 settings 表 + 改 config.yaml YAML 三键 + docker restart)+ admin 路由(GET/PUT,密钥掩码)+ 前端 Accounts 页卡片。settings 表为真相源,config.yaml 是同步目标。
**Tech Stack:** Rust(axum, sqlx, serde_yaml<新增依赖>, tokio::process)、React/TS。
> **构建/测试:** `cargo build -j 2`(-j2 防 OOM)、`cargo test -p boosapi --lib pixelle_connection`、`npm run typecheck:web`。分支 `feat/comfy-cloud-adapter`。Pixelle compose 目录 `/opt/Pixelle-Video`,config `/opt/Pixelle-Video/config.yaml`。
---
## 文件结构
- Modify `Cargo.toml` — 加 `serde_yaml` 依赖。
- Modify `src/config.rs` — `PixelleVideoConfig` 加 `config_path` / `compose_dir` 两字段(默认 `/opt/Pixelle-Video/config.yaml`、`/opt/Pixelle-Video`)。
- Create `src/services/pixelle_connection.rs` — read/save settings + sync_to_pixelle(改 yaml)+ restart。
- Modify `src/services/mod.rs` — `pub mod pixelle_connection;`。
- Create `src/routes/admin/pixelle_connection.rs` — GET/PUT handler(掩码)。
- Modify `src/routes/admin/mod.rs` — 声明子模块 + 挂 2 条路由。
- Modify `src/web/api.ts` — adminApi 加 getPixelleConnection / savePixelleConnection。
- Modify `src/web/pages/Accounts.tsx` — 加「Pixelle 连接」卡片。
---
<!-- TASKS -->
## Task 1: 加 serde_yaml 依赖 + config 路径字段
**Files:**
- Modify: `Cargo.toml`
- Modify: `src/config.rs`
- [ ] **Step 1: Cargo.toml 加 serde_yaml**
在 `[dependencies]` 段加(版本对齐项目 serde 生态,用 0.9):
```toml
serde_yaml = "0.9"
```
- [ ] **Step 2: PixelleVideoConfig 加两字段(src/config.rs,struct 内 timeout_secs 后)**
在 `pub timeout_secs: u64,` 之后加:
```rust
#[serde(default = "default_pixelle_config_path")]
pub config_path: String,
#[serde(default = "default_pixelle_compose_dir")]
pub compose_dir: String,
```
- [ ] **Step 3: 加默认值函数(src/config.rs,default_pixelle_backend_provider 附近)**
```rust
fn default_pixelle_config_path() -> String {
"/opt/Pixelle-Video/config.yaml".to_string()
}
fn default_pixelle_compose_dir() -> String {
"/opt/Pixelle-Video".to_string()
}
```
- [ ] **Step 4: PixelleVideoConfig::default() 补两字段**
找到 `impl Default for ...` 或 `PixelleVideoConfig::default()`(约 line 207 区域,有 `api_base_url: Some(...)` 那个),在其字段列表末尾加:
```rust
config_path: default_pixelle_config_path(),
compose_dir: default_pixelle_compose_dir(),
```
- [ ] **Step 5: 构建确认**
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
Expected: `Finished`。
- [ ] **Step 6: Commit**
```bash
cd /root/metapi && git add Cargo.toml Cargo.lock src/config.rs
git commit -m "feat(pixelle): 加 serde_yaml + Pixelle config 路径配置项
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T2 -->
## Task 2: pixelle_connection service(settings + yaml 同步)
**Files:**
- Create: `src/services/pixelle_connection.rs`
- Modify: `src/services/mod.rs`
- [ ] **Step 1: src/services/mod.rs 加模块声明**
```rust
pub mod pixelle_connection;
```
- [ ] **Step 2: 写失败测试 `src/services/pixelle_connection.rs`(含结构 + sync 测试)**
```rust
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PixelleConnection {
pub runninghub_api_key: Option<String>,
pub comfyui_url: Option<String>,
pub comfyui_api_key: Option<String>,
}
const KEY_RH: &str = "pixelle.runninghub_api_key";
const KEY_URL: &str = "pixelle.comfyui_url";
const KEY_CK: &str = "pixelle.comfyui_api_key";
pub async fn read_connection(db: &SqlitePool) -> AppResult<PixelleConnection> {
async fn get(db: &SqlitePool, k: &str) -> AppResult<Option<String>> {
Ok(sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(k).fetch_optional(db).await?)
}
Ok(PixelleConnection {
runninghub_api_key: get(db, KEY_RH).await?,
comfyui_url: get(db, KEY_URL).await?,
comfyui_api_key: get(db, KEY_CK).await?,
})
}
pub async fn save_connection(db: &SqlitePool, conn: &PixelleConnection) -> AppResult<()> {
async fn put(db: &SqlitePool, k: &str, v: &Option<String>) -> AppResult<()> {
if let Some(val) = v {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(k).bind(val).execute(db).await?;
}
Ok(())
}
put(db, KEY_RH, &conn.runninghub_api_key).await?;
put(db, KEY_URL, &conn.comfyui_url).await?;
put(db, KEY_CK, &conn.comfyui_api_key).await?;
Ok(())
}
/// 改 config.yaml 的 comfyui.{runninghub_api_key,comfyui_url,comfyui_api_key} 三键,其余保留。
/// 改前备份到 <path>.bak-<ts>。返回备份路径。
pub fn sync_to_pixelle(config_path: &str, conn: &PixelleConnection) -> AppResult<String> {
let content = std::fs::read_to_string(config_path)
.map_err(|e| AppError::ServiceUnavailable(format!("读取 Pixelle config 失败: {e}")))?;
let mut doc: serde_yaml::Value = serde_yaml::from_str(&content)
.map_err(|e| AppError::ServiceUnavailable(format!("解析 config.yaml 失败: {e}")))?;
let comfyui = doc.get_mut("comfyui")
.ok_or_else(|| AppError::ServiceUnavailable("config.yaml 缺少 comfyui 段".into()))?;
let map = comfyui.as_mapping_mut()
.ok_or_else(|| AppError::ServiceUnavailable("comfyui 段不是映射".into()))?;
if let Some(v) = &conn.runninghub_api_key {
map.insert("runninghub_api_key".into(), serde_yaml::Value::String(v.clone()));
}
if let Some(v) = &conn.comfyui_url {
map.insert("comfyui_url".into(), serde_yaml::Value::String(v.clone()));
}
if let Some(v) = &conn.comfyui_api_key {
map.insert("comfyui_api_key".into(), serde_yaml::Value::String(v.clone()));
}
let ts = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
let backup = format!("{config_path}.bak-{ts}");
std::fs::copy(config_path, &backup)
.map_err(|e| AppError::ServiceUnavailable(format!("备份失败: {e}")))?;
let out = serde_yaml::to_string(&doc)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
std::fs::write(config_path, out)
.map_err(|e| AppError::ServiceUnavailable(format!("写入 config 失败: {e}")))?;
Ok(backup)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_updates_three_keys_preserves_rest() {
let dir = std::env::temp_dir().join(format!("pxtest_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.yaml");
std::fs::write(&path,
"project_name: Pixelle-Video\ncomfyui:\n comfyui_url: https://old\n runninghub_api_key: oldkey\n comfyui_api_key: oldck\n runninghub_concurrent_limit: 1\n").unwrap();
let conn = PixelleConnection {
runninghub_api_key: Some("newkey".into()),
comfyui_url: Some("http://new".into()),
comfyui_api_key: Some("newck".into()),
};
let backup = sync_to_pixelle(path.to_str().unwrap(), &conn).unwrap();
assert!(std::path::Path::new(&backup).exists());
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("newkey"));
assert!(after.contains("http://new"));
assert!(after.contains("newck"));
// 保留其余键
assert!(after.contains("runninghub_concurrent_limit"));
assert!(after.contains("project_name"));
std::fs::remove_dir_all(&dir).ok();
}
}
```
- [ ] **Step 3: 运行测试,确认失败**
Run: `cd /root/metapi && cargo test -p boosapi --lib services::pixelle_connection 2>&1 | tail -15`
Expected: FAIL —— 模块/函数未定义(首次编译错误)。
- [ ] **Step 4: 实现**
Step 2 的代码即实现(测试与实现同文件)。确保 mod.rs 已声明。
- [ ] **Step 5: 运行测试,确认通过**
Run: `cd /root/metapi && cargo test -p boosapi --lib services::pixelle_connection 2>&1 | tail -10`
Expected: PASS(1 个测试)。
- [ ] **Step 6: Commit**
```bash
cd /root/metapi && git add src/services/mod.rs src/services/pixelle_connection.rs
git commit -m "feat(pixelle): 连接凭证 service(settings 读写 + config.yaml 同步)
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T3 -->
## Task 3: restart helper + admin 路由(掩码)
**Files:**
- Modify: `src/services/pixelle_connection.rs`(加 restart)
- Create: `src/routes/admin/pixelle_connection.rs`
- Modify: `src/routes/admin/mod.rs`
- [ ] **Step 1: 加 restart_pixelle(pixelle_connection.rs,sync_to_pixelle 之后)**
```rust
pub async fn restart_pixelle(compose_dir: &str) -> AppResult<()> {
let output = tokio::process::Command::new("docker")
.args(["compose", "restart", "api"])
.current_dir(compose_dir)
.output()
.await
.map_err(|e| AppError::ServiceUnavailable(format!("docker compose restart 启动失败: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AppError::ServiceUnavailable(format!(
"Pixelle 重启失败: {}", stderr.trim()
)));
}
Ok(())
}
```
- [ ] **Step 2: 写 admin 路由 `src/routes/admin/pixelle_connection.rs`**
```rust
use crate::error::AppResult;
use crate::services::pixelle_connection::{self, PixelleConnection};
use crate::AppState;
use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
const MASK_SENTINEL: &str = "••••";
fn mask(v: &Option<String>) -> Value {
match v {
Some(s) if s.len() > 4 => json!(format!("{}{}", MASK_SENTINEL, &s[s.len()-4..])),
Some(s) if !s.is_empty() => json!(MASK_SENTINEL),
_ => Value::Null,
}
}
pub async fn get_connection(State(state): State<AppState>) -> AppResult<Json<Value>> {
let c = pixelle_connection::read_connection(&state.db).await?;
Ok(Json(json!({
"success": true,
"connection": {
// url 不算敏感,明文回;两个 key 掩码
"comfyuiUrl": c.comfyui_url,
"runninghubApiKey": mask(&c.runninghub_api_key),
"comfyuiApiKey": mask(&c.comfyui_api_key),
}
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdatePayload {
runninghub_api_key: Option<String>,
comfyui_url: Option<String>,
comfyui_api_key: Option<String>,
}
fn keep_if_not_mask(incoming: Option<String>) -> Option<String> {
// 掩码值(以 •••• 开头)表示"未修改",跳过
match incoming {
Some(s) if s.starts_with(MASK_SENTINEL) => None,
other => other,
}
}
pub async fn put_connection(
State(state): State<AppState>,
Json(p): Json<UpdatePayload>,
) -> AppResult<Json<Value>> {
let conn = PixelleConnection {
runninghub_api_key: keep_if_not_mask(p.runninghub_api_key),
comfyui_url: p.comfyui_url, // url 明文,不掩码
comfyui_api_key: keep_if_not_mask(p.comfyui_api_key),
};
pixelle_connection::save_connection(&state.db, &conn).await?;
let backup = pixelle_connection::sync_to_pixelle(&state.config.pixelle_video.config_path, &conn)?;
let restart = pixelle_connection::restart_pixelle(&state.config.pixelle_video.compose_dir).await;
match restart {
Ok(()) => Ok(Json(json!({ "success": true, "backup": backup, "restarted": true }))),
Err(e) => Ok(Json(json!({
"success": true, "backup": backup, "restarted": false,
"warning": format!("配置已写入,但 Pixelle 重启失败,请手动重启: {e}")
}))),
}
}
```
- [ ] **Step 3: src/routes/admin/mod.rs 声明子模块 + 挂路由**
模块声明区(`mod users;` 附近)加:
```rust
mod pixelle_connection;
```
在 `routes()` 的路由链里(`/api/admin/user-balances` 那一带)加:
```rust
.route(
"/api/admin/pixelle/connection",
get(pixelle_connection::get_connection).put(pixelle_connection::put_connection),
)
```
确认 `get`/`put` 已从 axum::routing 引入(文件顶部应已 `use axum::routing::{get, post, ...}`;若缺 put 则补)。
- [ ] **Step 4: 构建确认**
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
Expected: `Finished`。
- [ ] **Step 5: Commit**
```bash
cd /root/metapi && git add src/services/pixelle_connection.rs src/routes/admin/pixelle_connection.rs src/routes/admin/mod.rs
git commit -m "feat(pixelle): 连接凭证 admin 端点(GET/PUT 掩码 + 重启)
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T4 -->
## Task 4: 前端连接管理卡片
**Files:**
- Modify: `src/web/api.ts`
- Modify: `src/web/pages/Accounts.tsx`
- [ ] **Step 1: api.ts adminApi 加方法**
在 adminApi 对象里(找 `request(` 范式的 admin 方法群)加:
```typescript
getPixelleConnection: () =>
request<{ success: boolean; connection: { comfyuiUrl?: string | null; runninghubApiKey?: string | null; comfyuiApiKey?: string | null } }>("/api/admin/pixelle/connection"),
savePixelleConnection: (data: { runninghubApiKey?: string; comfyuiUrl?: string; comfyuiApiKey?: string }) =>
request<{ success: boolean; backup?: string; restarted?: boolean; warning?: string }>("/api/admin/pixelle/connection", {
method: "PUT",
body: JSON.stringify(data),
}),
```
- [ ] **Step 2: Accounts.tsx 加「Pixelle 连接」卡片**
先读 `src/web/pages/Accounts.tsx` 顶部,确认 `api`/`adminApi` 的导入名和 toast 用法。在页面主体某处(如页尾)加一个卡片组件块。最小实现(state + 加载 + 保存):
```tsx
// —— Pixelle 连接(加在组件内 state 区)——
const [pxConn, setPxConn] = useState({ comfyuiUrl: '', runninghubApiKey: '', comfyuiApiKey: '' });
const [pxSaving, setPxSaving] = useState(false);
useEffect(() => {
adminApi.getPixelleConnection()
.then((r) => setPxConn({
comfyuiUrl: r.connection?.comfyuiUrl || '',
runninghubApiKey: r.connection?.runninghubApiKey || '',
comfyuiApiKey: r.connection?.comfyuiApiKey || '',
}))
.catch(() => {});
}, []);
const savePxConn = async () => {
setPxSaving(true);
try {
const res = await adminApi.savePixelleConnection({
runninghubApiKey: pxConn.runninghubApiKey || undefined,
comfyuiUrl: pxConn.comfyuiUrl || undefined,
comfyuiApiKey: pxConn.comfyuiApiKey || undefined,
});
if (res.warning) toast.error(res.warning);
else toast.success('已保存并重启 Pixelle,约数秒生效');
} catch (e: any) {
toast.error(e?.message || '保存失败');
} finally {
setPxSaving(false);
}
};
```
卡片 JSX(加到 return 的合适位置,样式对齐页面现有 card):
```tsx
<div className="card" style={{ marginTop: 16, padding: 16 }}>
<h3>Pixelle 连接</h3>
<label style={{ display: 'block', marginBottom: 8 }}>
<span>ComfyUI URL</span>
<input value={pxConn.comfyuiUrl} onChange={(e) => setPxConn((s) => ({ ...s, comfyuiUrl: e.target.value }))} placeholder="http://172.17.0.1:4000/comfy-cloud" style={{ width: '100%' }} />
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<span>RunningHub API Key</span>
<input value={pxConn.runninghubApiKey} onChange={(e) => setPxConn((s) => ({ ...s, runninghubApiKey: e.target.value }))} placeholder="留空••••表示不改" style={{ width: '100%' }} />
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<span>Comfy Cloud API Key</span>
<input value={pxConn.comfyuiApiKey} onChange={(e) => setPxConn((s) => ({ ...s, comfyuiApiKey: e.target.value }))} placeholder="留空••••表示不改" style={{ width: '100%' }} />
</label>
<button type="button" className="btn btn-primary" disabled={pxSaving} onClick={savePxConn}>
{pxSaving ? '保存中...' : '保存并重启 Pixelle'}
</button>
</div>
```
> 注:`useState`/`useEffect` 若未导入需补;`adminApi`/`api` 用 Accounts.tsx 实际的导入名(读文件确认是 `api` 还是 `adminApi`)。若提交掩码值(`••••...`)后端会跳过不改,符合预期。
- [ ] **Step 3: typecheck**
Run: `cd /root/metapi && npm run typecheck:web 2>&1 | tail -5`
Expected: 无 TS 错误。
- [ ] **Step 4: Commit**
```bash
cd /root/metapi && git add src/web/api.ts src/web/pages/Accounts.tsx
git commit -m "feat(pixelle): 连接管理页加 Pixelle 连接卡片
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T5 -->
## Task 5: 部署 + 端到端验证
**Files:** 无(部署 + 运行时)
- [ ] **Step 1: 重建并重启 BoosAPI(含前后端改动)**
```bash
cd /root/metapi && ./build-and-run.sh --fast-release 2>&1 | tail -15
```
Expected: 构建成功,BoosAPI 起在 :4000。动线上服务,确认时机。
- [ ] **Step 2: 连接管理页确认「Pixelle 连接」卡片**
硬刷新管理控制台连接管理页(`/accounts`),确认出现「Pixelle 连接」卡片,
ComfyUI URL 显示当前值,两个 key 显示掩码(`••••xxxx`)。
- [ ] **Step 3: 改一个值测试同步**
把 RunningHub API Key 改成一个新值,保存。确认:
```bash
grep runninghub_api_key /opt/Pixelle-Video/config.yaml | sed 's/[a-f0-9]\{20\}/.../'
ls -t /opt/Pixelle-Video/config.yaml.bak-* | head -1
rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
```
Expected: config.yaml 的 key 变成新值;有新备份文件;容器重启后 healthy。
- [ ] **Step 4: 掩码不改测试**
只改 ComfyUI URL、两个 key 保持掩码原值,保存。确认 config.yaml 里两个 key **没变**
(掩码值被后端跳过),URL 变了。
- [ ] **Step 5: 成功标准**
UI 改 Pixelle 凭证 → settings 表存值 + config.yaml 同步 + 容器重启,不再需要手动编辑文件。
---
## 完成标准
- 后端:pixelle_connection service(读写 settings + sync_to_pixelle + restart)+ admin GET/PUT 端点,`cargo build -j 2` 通过,service 单测通过。
- 前端:连接管理页有「Pixelle 连接」卡片,typecheck 干净。
- 端到端:UI 改凭证 → config.yaml 同步 + 容器重启生效;掩码值不误改。
- 向后兼容:不改 Pixelle 源码;config.yaml 其余键保留;每次改前备份。
## 已知风险 / 备注
- serde_yaml 重序列化会丢 config.yaml 注释(已接受;每次有时间戳备份兜底)。
- BoosAPI 进程需有 docker compose 执行权限 + config.yaml 读写权限(同宿主 root 运行,满足)。
- Task 5 重启 BoosAPI 动线上服务,确认时机。
- 备份文件会累积(config.yaml.bak-*),长期可加清理,本期不做。
@@ -1,244 +0,0 @@
# workflow_ui.rs 拆分(8 子模块)实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 `src/routes/workflow_ui.rs`(6181 行)按职责拆成 `src/routes/workflow_ui/` 目录 8 个子模块,纯机械移动、零逻辑改动,对外接口不变。
**Architecture:** 先建目录把原文件变 `mod.rs`,再逐个子模块把相关函数剪切出去(`pub(super)` 可见性 + 补 use),每步 cargo 编译验证。Rust 编译器是主安全网。
**Tech Stack:** Rust (axum, sqlx)。验证:`cargo build -j 2`、`cargo test -p boosapi --lib routes::workflow_ui`。
> **核心纪律:** 纯搬运,函数体一字不改;只改可见性 + use 路径。不优化、不改逻辑、不动路由路径。每抽完一组即编译+测试+提交。分支 `feat/comfy-cloud-adapter`。
> **验证基线(开工前先记录):** 跑 `cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"` 记下当前测试数(应 5 个),全程不得减少。
---
## 文件结构(目标)
```
src/routes/workflow_ui/
mod.rs — routes() + 共享类型(WorkflowCaller/各 Query/Payload struct)+ 跨模块小工具 + 子模块声明 + 测试入口
assets.rs — UI 静态资源服务/嵌入目录/index/static/serve_file/官方模板资源
auth.rs — caller 解析
node_defs.rs — 节点定义 + 工作流模板图
models.rs — 模型分类 + folder 模型
comfy_proxy.rs — ComfyUI API 代理 handler(settings/userdata/object_info/...)
validate.rs — 工作流校验
queue.rs — 队列/历史/prompt/view
```
> 当前 `src/routes/workflow_ui.rs`(单文件)→ 变为 `src/routes/workflow_ui/mod.rs` + 7 个兄弟文件。
> `src/routes/mod.rs` 的 `mod workflow_ui;` 声明无需改(目录模块同名,Rust 自动找 mod.rs)。
---
<!-- TASKS -->
## Task 0: 文件转目录模块(零拆分,先搭壳)
**Files:**
- Move: `src/routes/workflow_ui.rs` → `src/routes/workflow_ui/mod.rs`
- [ ] **Step 1: 记录验证基线**
Run: `cd /root/metapi && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`
Expected: 记下测试数(预期 `5 passed`)。后续每步此数不得减少。
- [ ] **Step 2: 把文件移成目录模块**
```bash
cd /root/metapi
mkdir -p src/routes/workflow_ui
git mv src/routes/workflow_ui.rs src/routes/workflow_ui/mod.rs
```
- [ ] **Step 3: 编译 + 测试验证(内容没动,应原样通过)**
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`
Expected: `Finished`;测试数与基线一致。
- [ ] **Step 4: Commit**
```bash
cd /root/metapi && git add src/routes/workflow_ui.rs src/routes/workflow_ui/mod.rs
git commit -m "refactor(workflow_ui): 文件转目录模块(mod.rs,内容不变)
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T1 -->
## Task 1: 抽 auth.rs(最小、最低风险,先验证流程)
**Files:**
- Create: `src/routes/workflow_ui/auth.rs`
- Modify: `src/routes/workflow_ui/mod.rs`
要搬的函数(用 grep 定位,函数体一字不改):`caller_from_headers`、`auth_caller`、`caller_user_id`、`caller_is_admin`。它们依赖类型 `WorkflowCaller`(留在 mod.rs)。
- [ ] **Step 1: 在 mod.rs 顶部加子模块声明**
在 mod.rs 的 `use` 区之后(`pub fn routes()` 之前)加:
```rust
mod auth;
```
- [ ] **Step 2: 确认这 4 个函数的精确范围 + WorkflowCaller 可见性**
Run: `cd /root/metapi && rtk proxy grep -nE "fn caller_from_headers|fn auth_caller|fn caller_user_id|fn caller_is_admin|enum WorkflowCaller" src/routes/workflow_ui/mod.rs`
记录每个函数起止行。`WorkflowCaller` 留 mod.rs,改成 `pub(super) enum WorkflowCaller`(让子模块可用)。
- [ ] **Step 3: 新建 auth.rs,粘入 4 个函数(函数体不改,补 use + 改可见性)**
新建 `src/routes/workflow_ui/auth.rs`,结构:
```rust
use super::WorkflowCaller;
use crate::error::AppResult;
use crate::AppState;
use axum::http::HeaderMap;
// (按 4 个函数实际用到的依赖补齐 use:validate_jwt、find_downstream_key_by_token 等——
// 从 mod.rs 顶部现有 use 里照抄它们需要的那几条)
pub(super) fn caller_from_headers(state: &AppState, headers: &HeaderMap) -> AppResult<WorkflowCaller> {
// ←原函数体一字不改粘进来
}
pub(super) fn caller_user_id(caller: &WorkflowCaller) -> Option<i64> {
// ←原体
}
pub(super) fn caller_is_admin(caller: &WorkflowCaller) -> bool {
// ←原体
}
pub(super) async fn auth_caller(state: &AppState, headers: &HeaderMap) -> AppResult<WorkflowCaller> {
// ←原体
}
```
> 关键:这 4 个函数从 mod.rs **删除**;其在 mod.rs/其他子模块的调用点改成 `auth::caller_from_headers(...)` 等(mod.rs 内加 `use auth::{caller_from_headers, auth_caller, caller_user_id, caller_is_admin};` 即可不改调用点写法)。
- [ ] **Step 4: mod.rs 加 use 引回,删除原 4 函数**
mod.rs 顶部子模块声明后加:
```rust
use auth::{auth_caller, caller_from_headers, caller_is_admin, caller_user_id};
```
并从 mod.rs 删掉这 4 个函数的原定义。
- [ ] **Step 5: 编译 + 测试**
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`
Expected: `Finished`;测试数 == 基线。若编译报「未使用的 use」「私有」「未解析」,按提示补 use / 调可见性(纯机械修),不改逻辑。
- [ ] **Step 6: Commit**
```bash
cd /root/metapi && git add src/routes/workflow_ui/mod.rs src/routes/workflow_ui/auth.rs
git commit -m "refactor(workflow_ui): 抽出 auth 子模块
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T2 -->
## 通用抽取流程(每个子模块任务都按这 6 步)
每个抽取任务对一组函数执行:
1. mod.rs 顶部加 `mod <name>;` 声明。
2. grep 定位本组函数精确范围;凡被本组函数引用、但定义留在别处的类型/函数,把那些定义改 `pub(super)`。
3. 新建 `<name>.rs`,粘入本组函数(**函数体一字不改**),顶部按实际依赖补 `use`(从 mod.rs 现有 use 照抄需要的;跨模块的用 `use super::...` 或 `use super::<other_mod>::...`),函数可见性改 `pub(super)`。
4. 从 mod.rs 删掉这些函数原定义;mod.rs 顶部加 `use <name>::{...};` 把它们引回(调用点写法不变)。
5. `cargo build -j 2` + `cargo test -p boosapi --lib routes::workflow_ui`。编译报错按提示机械修(补 use / 调可见性),不改逻辑。测试数不得低于基线。
6. 提交:`git add src/routes/workflow_ui/mod.rs src/routes/workflow_ui/<name>.rs && git commit -m "refactor(workflow_ui): 抽出 <name> 子模块"`(带 Co-Authored-By)。
> 注:若某函数同时被多个子模块用,保留在 mod.rs 顶层并 `pub(super)`,各子模块 `use super::该函数`。
> 共用小工具(guess_mime_from_path / parse_boolish / safe_view_path / user_home / default_history_limit
> / workspace_kind_from_mime)统一留 mod.rs 顶层 `pub(super)`,不下沉到子模块。
## Task 2: 抽 node_defs.rs(节点定义 + 工作流模板图)
**Files:** Create `src/routes/workflow_ui/node_defs.rs`;Modify `src/routes/workflow_ui/mod.rs`
要搬的函数(grep 定位):`known_output_ports`、`core_node_definitions`、`workflow_template_payload`、`core_workflow_template_index`、`inject_boosapi_workflow_template_payload`、`default_boosapi_workflow_graph`、`text_to_video_workflow_graph`、`port_name`、`source_node_type`、`normalize_prompt_payload`、`prompt_link`、`node_type_def_to_comfy`、`input_spec_to_comfy`、`output_specs`、`output_is_list_specs`。
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `node_defs`。
注意:这些函数用到 `WorkflowGraph` / `NodeTypeDefinition` 类型——确认它们的定义位置(可能在 crate::workflow 或 mod.rs),mod.rs 内的留存定义改 `pub(super)`,node_defs.rs 用 `use super::...` 或 `use crate::...` 引入。
验证命令:`cd /root/metapi && cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`,期望 Finished + 测试数≥基线。
提交 message:`refactor(workflow_ui): 抽出 node_defs 子模块`。
<!-- T3 -->
## Task 3: 抽 models.rs(模型分类 + folder 模型)
**Files:** Create `src/routes/workflow_ui/models.rs`;Modify `src/routes/workflow_ui/mod.rs`
要搬的函数:`is_exact_workflow_model`、`is_image_workflow_model`、`is_video_workflow_model`、`is_audio_workflow_model`、`is_embedding_workflow_model`、`is_3d_workflow_model`、`is_vision_workflow_model`、`is_chat_workflow_model`、`workflow_route_models`、`image_model_choices`、`workflow_folder_models`、`workflow_folder_is_api_only`、`workflow_folder_has_route_models`、`workflow_folder_route_model_items`、`combo_choices_from_node_input`、`combo_fields_from_node`、`native_folder_models_from_object_info`。
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `models`。
注意:`workflow_route_models` 是 `async fn` 且用 `state.db`,补 `use crate::AppState;` 等。
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 models 子模块`。
<!-- T4 -->
## Task 4: 抽 assets.rs(UI 静态资源服务)
**Files:** Create `src/routes/workflow_ui/assets.rs`;Modify `src/routes/workflow_ui/mod.rs`
要搬的函数:`workflow_ui_root_for_config`、`resolve_workflow_ui_dir`、`embedded_workflow_ui_for_config`、`workflow_ui_dir_is_usable`、`embedded_workflow_ui_is_usable`、`workflow_ui_index_is_placeholder`、`workflow_ui_runtime`、`workflow_ui_build_metadata`、`workflow_cloud_api_key`、`workflow_ui_index`、`workflow_ui_static`、`official_workflow_template_asset`、`serve_file`。
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `assets`。
注意:用到 `include_dir::Dir`、`PathBuf`、`Html`、`Response` 等,按实际补 use。`serve_file` 若被 queue/view 模块也用到,则保留 mod.rs 顶层 `pub(super)`(实现时看引用)。
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 assets 子模块`。
<!-- T5 -->
## Task 5: 抽 comfy_proxy.rs(ComfyUI API 代理 handler)
**Files:** Create `src/routes/workflow_ui/comfy_proxy.rs`;Modify `src/routes/workflow_ui/mod.rs`
要搬的函数:`get_user_config`、`create_user`、`get_settings`、`get_setting`、`store_settings`、`store_setting`、`userdata_list`、`userdata_get`、`userdata_store`、`userdata_delete`、`move_userdata`、`extensions`、`workflow_templates`、`embeddings`、`object_info`、`system_stats`。
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `comfy_proxy`。
注意:这些是路由 handler(多 `async fn ...(State, HeaderMap) -> AppResult<...>`),依赖 `auth_caller`(已在 auth.rs)→ 用 `use super::auth_caller;` 或 `super::auth::auth_caller`。补 axum extract / Json / Response use。
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 comfy_proxy 子模块`。
<!-- T6 -->
## Task 6: 抽 validate.rs(工作流校验)
**Files:** Create `src/routes/workflow_ui/validate.rs`;Modify `src/routes/workflow_ui/mod.rs`
要搬的函数:`validate_workflow`、`validate_workflow_against_object_info`、`validate_workflow_input_file`。
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `validate`。
注意:依赖 node_defs/models 的函数(如 normalize_prompt_payload、combo_fields_from_node)→ 用 `use super::node_defs::...` / `use super::models::...`(确保那些函数已 `pub(super)`)。WorkflowValidateQuery struct 留 mod.rs `pub(super)`。
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 validate 子模块`。
<!-- T7 -->
## Task 7: 抽 queue.rs(队列/历史/prompt/view)+ 收尾
**Files:** Create `src/routes/workflow_ui/queue.rs`;Modify `src/routes/workflow_ui/mod.rs`
要搬的函数:`queue_status`、`queue`、`queue_mutate`、`history`、`history_mutate`、`history_by_id`、`queue_prompt`、`repair_native_prompt_payload`、`queue_boosapi_prompt`、`interrupt`、`view`、`internal_logs`、`internal_logs_raw`。
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `queue`。
注意:`repair_native_prompt_payload` 很长(2814-3474),依赖 node_defs/models 多个函数;`view` 用 serve_file/safe_view_path。补 `use super::...`。PromptPayload/HistoryQuery/ViewQuery/QueueMutation/UserDataQuery/UploadQuery struct 留 mod.rs `pub(super)`(被多模块用)。
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 queue 子模块`。
- [ ] **Step 7: 最终验收**
Run:
```bash
cd /root/metapi
cargo build -j 2 2>&1 | tail -3
cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"
for f in mod auth node_defs models assets comfy_proxy validate queue; do echo -n "$f.rs: "; wc -l < src/routes/workflow_ui/$f.rs; done
```
Expected: Finished;测试数≥基线;每个子文件行数显著小于原 6181(mod.rs 应只剩 routes()+共享类型+小工具,目标 <1500 行;无单文件超 ~2000)。
- [ ] **Step 8: 冒烟(手动)**
部署后打开工作流 UI、跑一个 prompt,确认行为与拆分前一致(路由路径、handler 行为不变)。
<!-- END -->
@@ -1,149 +0,0 @@
# 计费闭环系统设计
日期:2026-06-16
状态:设计已获批,待写实现计划
## 背景
BoosAPI 现有计费是零散拼接的:每个用户的 downstream API key 有 `max_cost`(美元上限)/ `used_cost`(已消耗),proxy 请求路径在 `used_cost >= max_cost` 时硬拦截返回 429;充值通过 Stripe 自动到账或 comfy_cloud 管理员确认到账,调用 `credit_user_balance` 增加 `max_cost`;新用户默认 $1 额度;usage_logs 按 key 记每笔消费。
缺口:ComfyUI 工作流执行不扣额度、无积分体系、无消费明细可视化、无充值档位配置、无低额度预警、无全量变动审计。
本设计把上述补齐为一个 **ledger-first(统一流水账)** 的计费闭环。
## 核心模型:双轨额度
- **USD 轨**:LLM 对话、生图、视频等所有走 `/v1/*` 的调用。沿用 `downstream_api_keys.max_cost/used_cost`。
- **工作流积分轨**:仅 ComfyUI 工作流执行。新建每用户独立的积分池,平台可管理。
两轨并行、互不换算。comfy_cloud 充值进积分轨;其他充值(Stripe)进 USD 轨。
## 数据模型
### 新表 `credit_ledger`(唯一真相源,所有变动记一行)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `user_id` | INTEGER NOT NULL | 所属用户 |
| `track` | TEXT NOT NULL | `usd` / `points` |
| `entry_type` | TEXT NOT NULL | `recharge` / `admin_adjust` / `consume` / `refund` / `grant` |
| `amount` | REAL NOT NULL | 正=增 负=减(usd 用美元,points 用积分) |
| `balance_after` | REAL NOT NULL | 该轨本次变动后余额快照 |
| `ref_type` | TEXT | `usage_log` / `recharge_record` / `manual` / `stripe` 等 |
| `ref_id` | TEXT | 关联记录 id/public_id |
| `model` | TEXT | 消费类:模型名或工作流名(供聚合) |
| `operator` | TEXT | 操作者:管理员名 / `system` / `user` |
| `note` | TEXT | 备注 |
| `created_at` | TEXT DEFAULT (datetime('now')) | |
索引:`(user_id, created_at DESC)`、`(track, entry_type)`、`(user_id, track)`。
### 新表 `user_credits`(积分余额缓存)
| 字段 | 类型 | 说明 |
|---|---|---|
| `user_id` | INTEGER PK | |
| `points_balance` | REAL NOT NULL DEFAULT 0 | 当前积分余额(热路径读写) |
| `points_total_recharged` | REAL NOT NULL DEFAULT 0 | 累计充值积分(统计) |
| `updated_at` | TEXT | |
### 新表 `recharge_tiers`(充值档位,admin 可配)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `track` | TEXT NOT NULL | `usd` / `points` |
| `amount_usd` | REAL NOT NULL | 支付金额(USD) |
| `grant_amount` | REAL NOT NULL | 到账量(USD 数或积分数) |
| `bonus` | REAL NOT NULL DEFAULT 0 | 赠送量 |
| `title` | TEXT | 档位标题 |
| `sort_order` | INTEGER DEFAULT 0 | 排序 |
| `enabled` | INTEGER DEFAULT 1 | 启用 |
### 余额派生策略:ledger + 缓存双写
- **USD 轨**:`max_cost/used_cost` 作快速余额缓存(拦截/展示零延迟读),每笔变动**同事务**写一条 `credit_ledger(track=usd)`。
- **积分轨**:`user_credits.points_balance` 作缓存,每笔变动**同事务**写 `credit_ledger(track=points)`。
- **关键不变量**:任何余额变动 = 缓存字段更新 + 一条 ledger,**同一 DB 事务**,事务失败整体回滚,绝不只改其一。
- ledger 是审计/明细/对账真相;缓存字段是热路径性能优化。
## 双轨记账接入
原则:所有动余额处,同事务"改缓存 + 写一条 ledger"。
### USD 轨
| 变动 | 现有代码 | 改造 |
|---|---|---|
| 充值到账 | `credit_user_balance`(payment.rs) | 同事务加写 ledger(usd, recharge) |
| 消费扣费 | `increment_key_usage`/`record_billing` | 同事务加写 ledger(usd, consume, model=模型名) |
| 管理员增减 | `adjust_user_balance` | 同事务加写 ledger(usd, admin_adjust, operator) |
| 额度拦截 | proxy `used_cost>=max_cost`→429 | 不变,补低额度预警信号 |
### 积分轨(基本新建)
| 变动 | 现状 | 新增 |
|---|---|---|
| 充值到账 | comfy_cloud 确认到账现加 USD | 改为加 `points_balance` + ledger(points, recharge) |
| 消费扣费 | 工作流不扣 | 工作流执行**成功后**按单价扣积分 + ledger(points, consume, model=工作流名);失败不扣 |
| 管理员发放/增减 | 无 | 新增 `adjust_user_credits` + ledger(points, admin_adjust) |
| 积分拦截 | 无 | 工作流执行**前**检查 `points_balance >= 单价`,不足拒绝;低额度预警 |
- 积分消费扣费点:`workflow/executor.rs` 的 `run_workflow` 成功收尾处(有 `ctx.user_id`),与现有"工作流按用户 key 记 USD 消费"平行。
- **comfy_cloud 充值改向积分轨**:行为变更,历史 USD **不迁移**,仅对切换后新充值生效,用 config 开关控制(可回滚)。
## 积分消费规则:A+C 混合
- **A 固定/分级单价**(主):工作流执行扣固定积分(config 默认值,可按工作流类型分级)。可预测、支持并发。
- **C 对账校准**(兜底):后台 cron 拉 Comfy 总余额对账,偏差超阈值提示管理员调单价。
## 四块功能
### A 消费明细(用户侧,明细+聚合)
- 新建 `GET /api/user/billing/ledger`:查该用户 credit_ledger,可按 track/type/时间过滤。
- 新建 `GET /api/user/billing/summary`:按天/按模型聚合,双轨分开。
- 扩展 `UsageRecords.tsx`:双轨切换 + 聚合汇总卡 + 明细列表。
### B 充值档位(本期可配置,订阅后期)
- 新建 `recharge_tiers` 表 + admin CRUD `GET/POST/PUT/DELETE /api/admin/recharge-tiers`。
- 改造用户充值:档位从 DB 读(替代 env 写死的 `USER_RECHARGE_OPTIONS`)。
- 扩展 `UserRecharge.tsx`(档位从接口拉)+ 新建 admin `RechargeTiers` 配置页。
### C 超额 + 预警(两轨硬拦 + 预警)
- USD 拦截已有(不动);积分拦截新建(工作流执行前)。
- 余额接口加 `lowBalance` 标志(余额 < 阈值,config 可配默认 20%)。
- Dashboard/充值页/视频助手:余额低显示预警条。
### D 审计对账(管理员侧,全量流水)
- 新建 `GET /api/admin/billing/ledger`:跨用户查 credit_ledger(按 user/track/type/时间过滤、分页)。
- 新建 admin `BillingAudit` 页:全量变动流水表(谁/何时/给谁/加减/原因),站点管理风格。
- 对账校准:cron 拉 Comfy 总余额,与积分消费 ledger 比对,偏差超阈值写 log + admin 告警标志。**不自动改数据**,仅提示,管理员手动调单价。
## 错误处理(不变量)
- 余额变动 = 缓存 + ledger 同事务;失败整体回滚。
- 消费扣费 best-effort 不阻断响应(沿用 record_billing 原则)。
- 积分轨:拦截是前置硬检查,扣费是后置;后置扣费失败已产出结果 → 记日志补偿。
- comfy 充值改向积分:config 开关控制,可回滚。
## 测试策略
- 单元:ledger 写入+缓存同步事务一致性;积分拦截(不足拒绝);单价扣费;充值进积分;管理员调整双轨。
- 复用现有 payment/billing 测试框架(in-memory SQLite)。
## 实现顺序(分 6 期,每期可独立上线)
1. **地基**:credit_ledger + user_credits 表 + 迁移;USD 轨记账接进 ledger(充值/消费/调整三处双写)。
2. **积分轨**:工作流积分扣费 + 拦截 + comfy 充值改进积分 + 管理员积分调整。
3. **A 消费明细**:用户侧 ledger/summary 接口 + UsageRecords 扩展。
4. **B 充值档位**:recharge_tiers 表 + admin 配置页 + 用户充值页接 DB。
5. **C 预警**:余额接口加 lowBalance + 前端预警条。
6. **D 审计 + 对账**:admin BillingAudit 页 + cron 对账校准。
## 新增清单小结
- **表**:credit_ledger(核心)、user_credits(积分缓存)、recharge_tiers(档位)。
- **后端接口**:user billing ledger/summary、admin recharge-tiers CRUD、admin billing ledger、adjust_user_credits、积分拦截/扣费。
- **前端页面**:admin RechargeTiers、admin BillingAudit;扩展 UsageRecords / UserRecharge / Dashboard。
- **cron**:Comfy 余额对账校准任务。
@@ -1,145 +0,0 @@
# Comfy Cloud 工作流执行适配器 — 设计
日期:2026-06-17
分支:`feat/comfy-cloud-adapter`
状态:已通过 brainstorming,待落实现计划
## 背景
数字人/图片工作流走 `selfhost` 生成视频时报 `Submit workflow failed: [401] AuthenticationRequired`。
根因经多层验证已锁定:
- Pixelle 的 comfykit 0.1.12 `HttpExecutor` 说的是 **ComfyUI 原生协议**:
`POST {base_url}/prompt`、`GET {base_url}/history/{id}`、鉴权用 `Authorization: Bearer`。
- 配置里 `comfyui_url = https://cloud.comfy.org`,但 **Comfy Cloud 的 API 契约完全不同**
(官方文档 https://docs.comfy.org/development/cloud/overview):
- 鉴权:`X-API-Key` 头(非 Bearer)。
- 提交:`POST /api/prompt`(带 `/api/` 前缀)。
- 轮询:`GET /api/job/{prompt_id}/status` → 状态 pending/in_progress/completed/failed/cancelled。
- 详情/产出:`GET /api/jobs/{prompt_id}`(产出按 node_id 聚合 images/video/audio)。
- 下载:`GET /api/view?filename=&subfolder=&type=output`(302 跳带签名 URL)。
- 接受标准 ComfyUI API-format 工作流。
- 已用官方方式验证 Comfy Cloud key 有效:`GET /api/user` 返回 200 `{"status":"active"}`。
所以这不是 key 失效,而是 comfykit 与 Comfy Cloud 协议不兼容(路径前缀 + 鉴权头 + 轮询端点三处全不同)。
## 目标
在 **BoosAPI** 里实现一个 **ComfyUI 原生协议 ↔ Comfy Cloud API 翻译适配器**:对外暴露
ComfyUI 原生端点(`/prompt`、`/history/{id}`、`/view`),内部翻译成 Comfy Cloud 的
`/api/*` 契约 + `X-API-Key` 鉴权。Pixelle/comfykit **一行代码都不改**,只把 `comfyui_url`
指向 BoosAPI 的适配端点即可。
Comfy Cloud 的 base URL + API Key 通过**站点管理**录入(新增 `comfy_cloud` 平台类型),
适配器从站点配置读取,而非散落的环境变量/配置文件。
## 已确认的关键决策
1. 采用 **Approach A**:站点管理只作「配置载体 + 前端入口」(存 base_url + X-API-Key),
执行逻辑由独立的 Comfy Cloud 适配器承担;**不**把 Comfy Cloud 塞进 `/v1/*` token 路由
(它是工作流执行 API,不是聊天协议,塞进 token-router 跑不通)。
2. 适配器对外说 ComfyUI 原生协议,Pixelle/comfykit 零改动,只改 `comfyui_url` 配置。
3. 鉴权翻译:入口端点不强制校验(本机互信)/出口注入 `X-API-Key`。
4. 分支 `feat/comfy-cloud-adapter`,与 NIT 工作无关,不混入。
## 非目标
- 不改 Pixelle / comfykit 源码。
- 不做 WebSocket 实时进度(comfykit HTTP 模式只轮询 `/history/{id}`,够用)。
- 不把 Comfy Cloud 接入 LLM token 路由/failover。
- 不做 Comfy Cloud 的计费集成(已有 `fetch_comfy_cloud_balance` 另说)。
## 架构
```
Pixelle(comfykit 0.1.12, 不改)
comfyui_url = http://<boosapi-host>/comfy-cloud
说 ComfyUI 原生协议: POST /prompt · GET /history/{id} · GET /view · Bearer(忽略)
│
▼
BoosAPI 适配端点(新增 src/routes/comfy_cloud_adapter.rs)
· POST /comfy-cloud/prompt → 翻译 → POST cloud.comfy.org/api/prompt
· GET /comfy-cloud/history/{id} → 翻译 → GET /api/job/{id}/status (+ /api/jobs/{id})
拼回 ComfyUI history 格式
· GET /comfy-cloud/view → 翻译 → GET /api/view (跟随 302)
鉴权: 出口注入 X-API-Key(从站点配置读)
│
▼
Comfy Cloud (https://cloud.comfy.org/api/*)
```
## 站点管理集成(配置载体)
- 前端 `SITE_PLATFORM_OPTIONS`(`src/web/pages/Sites.tsx`)新增一项
`{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API' }`。
- 站点记录复用现有字段:`url` 存 `https://cloud.comfy.org`,API Key 存入现有的
站点凭证字段(沿用站点已有的 key/header 存储方式;实现时确认 sites 表里承载 key 的列)。
- 适配器通过一个 helper `resolve_comfy_cloud_site(state) -> (base_url, api_key)`:
查 `platform = 'comfy_cloud'` 且 enabled 的站点,取其 url + key。无配置 → 明确报错。
## 适配端点详细行为
### POST /comfy-cloud/prompt
- 入参:ComfyUI 原生 body `{ "prompt": {...}, "client_id": "..." }`。
- 出口:`POST {base_url}/api/prompt`,头 `X-API-Key: {key}` + `Content-Type: application/json`,
body 原样转发(Comfy Cloud 接受相同的 `{prompt: ...}` 包裹)。
- 回包:Comfy Cloud 返回 `{ "prompt_id": "..." }`,原样回给 comfykit(字段名一致,无需改)。
### GET /comfy-cloud/history/{prompt_id}
comfykit 轮询此端点,期待 ComfyUI history 格式:
`{ "{prompt_id}": { "status": {...}, "outputs": { "{node_id}": { "images": [...] } } } }`。
适配器:
1. `GET {base_url}/api/job/{prompt_id}/status` 拿 `{ "status": "completed|in_progress|..." }`。
2. 若未完成(pending/in_progress)→ 回 `{}`(comfykit 据此继续轮询)。
3. 若 completed → `GET {base_url}/api/jobs/{prompt_id}` 拿产出,翻译成 ComfyUI history 形状:
```json
{ "{prompt_id}": {
"status": { "completed": true, "status_str": "success" },
"outputs": { /* node_id → {images|video|audio: [{filename,subfolder,type}]} */ }
} }
```
4. 若 failed/cancelled → 回带 `status_str: "error"` 的 history,让 comfykit 终止轮询并报错。
### GET /comfy-cloud/view
- 透传 query(`filename`/`subfolder`/`type`)→ `GET {base_url}/api/view?...`,头注入 X-API-Key。
- Comfy Cloud 回 302 签名 URL → 适配器跟随重定向取字节流回传(reqwest 默认跟随,
或手动取 Location 再 GET),Content-Type 透传。
## 数据流(comfykit 视角,全程无感)
1. comfykit `POST {comfyui_url}/prompt` → 适配器 → Comfy Cloud → 返回 prompt_id。
2. comfykit 轮询 `GET {comfyui_url}/history/{id}` → 适配器翻译 job 状态 → 未完成回 `{}`,
完成回 ComfyUI history。
3. comfykit 从 outputs 取 filename → `GET {comfyui_url}/view?filename=...` → 适配器取回字节。
## 错误处理
- 站点未配置 `comfy_cloud` → 适配端点返回 503 + 中文提示「未配置 Comfy Cloud 站点」。
- Comfy Cloud 返回非 2xx → 适配器透传状态码 + 上游错误体(便于排查,不吞错)。
- job failed/cancelled → 在 history 里标 error,comfykit 会抛 `Submit workflow failed` 类错误
(但这次是真实的执行失败,不是鉴权问题)。
## 路由挂载
`src/routes/mod.rs` 的 `build_router` 加 `.merge(comfy_cloud_adapter::routes())`。
端点前缀 `/comfy-cloud/*`,不需要 admin/user 鉴权(本机内 Pixelle → BoosAPI 互信;
如需收紧可后续加内部 token,本期不做)。
## 测试策略(TDD)
- 单测翻译函数:Comfy Cloud `job status + jobs detail` JSON → ComfyUI history JSON 的纯函数
(`comfy_cloud_job_to_history`),覆盖 completed / in_progress / failed 三种。
- 单测 `resolve_comfy_cloud_site`:有配置返回 (url,key);无配置报错。
- 用 wiremock 起一个假 Comfy Cloud,集成测 `POST /comfy-cloud/prompt` 透传 + X-API-Key 注入、
`GET /comfy-cloud/history/{id}` 轮询翻译(沿用项目现有 wiremock 测试范式)。
## 验证
- `cargo build -j 2`、`cargo test -p boosapi --lib comfy_cloud`。
- 真实联调:把 Pixelle `comfyui_url` 指向 `http://<boosapi>/comfy-cloud`,跑一次 selfhost 视频,
确认不再 401 且能取回产出。
## 提交约定
分支 `feat/comfy-cloud-adapter`,提交按逻辑拆分(站点平台类型 / 适配器 service + 端点 /
路由挂载 / 前端平台选项),沿用项目中文 commit 风格。
@@ -1,93 +0,0 @@
# 数字人接入 Comfy Cloud 视频工作流(完整视频入口)设计
日期:2026-06-18
分支:`feat/comfy-cloud-adapter`
状态:已通过调查,待用户复审
## 背景
LTX2.3 说话头像工作流(`comfy/video_digital_ltx2.json`)已就绪并通过 Comfy Cloud 校验
(节点+模型全兼容,probe `node_errors:{}`)。但 BoosAPI 数字人 UI 没有「视频/动画工作流」
入口,无法触发「肖像图 + 旁白音频 → 说话视频」这一步。本设计补齐贯穿三层的入口。
## 现状(调查结论)
- **BoosAPI 数字人前端 `src/web/pages/DigitalHuman.tsx`**:仅 3 个选择器——ttsWorkflow、
imageWorkflow(从 mediaWorkflows 选,默认含 "image" 的)、frameTemplate(HTML 模板)。
**无视频/动画工作流选择器**。流程是「生成图片 + TTS + HTML 模板合成」,不做说话头像动画。
- **BoosAPI 后端 `src/routes/digital_human.rs`** 的 `pixelle_request_body`(~line 2053):转发
text/mode/title/backend_provider/workflow_source/n_scenes/video_fps/frame_template/
template_params/prompt_prefix/bgm_*/media_workflow/tts_workflow/ref_audio。
**不转发 `api_video_workflow`**;`workflow_source` 写死 `runninghub`/`selfhost`(无 comfy)。
- **Pixelle `pixelle_video/pipelines/asset_based.py`**(~line 693):动画步读
`context.request.get("api_video_workflow")`;`audio_path` 仅当 `_get_api_workflow_info`
(查 `api_media`,只 `api/` 通道)匹配到 `audio_driven_i2v` 能力时才注入 → **comfy 工作流
拿不到 audio_path**。
- **Pixelle 工作流执行按前缀路由**:`runninghub` 走 RunningHub,其余(含 `comfy/`)走
`comfyui_url`(= BoosAPI `/comfy-cloud` 适配器)。`comfy/video_digital_ltx2.json` 已在
`/workflows/media` 列出(已加 `video_` 前缀)。
- 已完成的前置:comfy-cloud 适配器(已验证)、`comfy/video_digital_ltx2.json`(标记
image_path/audio_path/prompt,probe 通过)、media.py 转发 image_path(已打补丁)。
## 已确认决策
1. 加完整视频入口:前端选择器 + 后端转发 + Pixelle 音频注入,三层都改。
2. 视频工作流选择器列 `video_*` media 工作流(含 `comfy/video_digital_ltx2.json`)。
3. 单人肖像说话头像;不做多人。
## 三层改动设计
### 层 1:前端 `src/web/pages/DigitalHuman.tsx`
- 新增 state `apiVideoWorkflow`(默认空=不启用动画,保持现有「图片+HTML」行为向后兼容)。
- 新增「视频工作流(可选)」`<select>`:选项 = `mediaWorkflows.filter(name 含 "video")`,
含空选项「不生成视频(仅图片合成)」。
- 提交 payload 增加 `apiVideoWorkflow` 字段;draft 持久化同步。
### 层 2:BoosAPI 后端 `src/routes/digital_human.rs`
- `pixelle_request_body` 增加:
```rust
if let Some(value) = optional_string(body, &["apiVideoWorkflow", "api_video_workflow"]) {
request_body["api_video_workflow"] = json!(value);
}
```
- 若所选视频工作流以 `comfy/` 开头,确保 `workflow_source` 不会阻断(comfy 工作流靠前缀
路由,workflow_source 主要影响 media/tts 默认通道;api_video_workflow 独立按前缀执行,
无需改 workflow_source。实现时验证不冲突)。
- 可选转发 `api_video_params`(若前端给了音频驱动等参数)。本期最小化:只转发 api_video_workflow。
### 层 3:Pixelle `pixelle_video/pipelines/asset_based.py`
- 动画步注入 `audio_path` 的条件放宽:除 `api_media` 能力匹配外,**当 `api_video_workflow`
以 `comfy/` 开头时,也注入 `audio_path = frame.audio_path`**(comfy 工作流的 `$~audio_path!`
标记需要它)。最小改动:在 `audio_driven_i2v` 判断旁边加 comfy 前缀分支。
- 镜像内置文件,改后重启容器,备份到 `/opt/Pixelle-Video/patches/`。
## 数据流(目标)
```
DigitalHuman.tsx: 选 comfy/video_digital_ltx2.json (apiVideoWorkflow)
→ BoosAPI digital_human.rs: request_body.api_video_workflow
→ Pixelle /api/video/generate/async: context.request.api_video_workflow
→ asset_based.py 动画步: 注入 image_path(media.py已补)+ audio_path(本期补)+ prompt
→ media() comfy 前缀 → comfyui_url=/comfy-cloud 适配器 → Comfy Cloud LTX2.3
→ 说话视频 → 帧合成/HTML 模板 → 成片
```
## 验证
- 前端 typecheck:`npm run typecheck:web`。
- 后端构建:`cargo build -j 2`。
- Pixelle 改动:语法校验 + 重启 healthy。
- 端到端:数字人任务选 `comfy/video_digital_ltx2.json`,确认 Comfy Cloud 出现 job、
注入了 image_path+audio_path、出说话视频。
## 非目标
- 不改 comfy-cloud 适配器(已完成)。
- 不做多人对话数字人。
- 不重构数字人现有「图片+HTML 合成」路径(视频入口为可选叠加,默认不启用,向后兼容)。
- 不动 RunningHub 数字人路径。
## 提交约定
分支 `feat/comfy-cloud-adapter`。前端、BoosAPI 后端分别提交(项目内,受版本控制);
Pixelle 改动在容器内 + 备份 patches/(非本仓库)。中文 commit。
@@ -1,71 +0,0 @@
# 数字人脚本 LLM 模型可选 设计
日期:2026-06-18
分支:`feat/comfy-cloud-adapter`
状态:已通过 brainstorming,待用户复审
## 背景
数字人脚本生成的 LLM 模型现在写死在 Pixelle config(`llm.model = gpt-5.5`),用户无法在
数字人页面选用别的模型。本功能加一个「脚本模型(可选)」下拉,让用户自选生成脚本的 LLM。
生图模型已可通过现有「图片工作流」下拉选择,不在本范围。
## 已确认决策
1. 只做脚本 LLM 模型可选;生图模型已由「图片工作流」下拉覆盖,不动。
2. 下拉只列文本/对话模型(过滤掉生图/视频模型,避免选错导致生成失败)。
3. 默认空 = 用 Pixelle config 的默认模型(`deepseek-v4-flash`),向后兼容。实现时把 Pixelle
`config.yaml` 的 `llm.model` 设为 `deepseek-v4-flash`。
4. 三层改动(前端选择器 + 后端转发 + Pixelle 传 model)。
## 集成点(调查结论)
- Pixelle `llm_service.py`:`llm(prompt=, model=...)` **已支持按调用传 model 覆盖**
(优先级:参数 > config)。
- Pixelle `asset_based.py` 脚本生成调用 `self.core.llm(prompt=, response_type=VideoScript, ...)`
**当前未传 model** → 需补 `model=context.request.get("llm_model")`。
- BoosAPI `digital_human.rs` `pixelle_request_body` 转发字段(媒体/tts 工作流等),
**未转发 llm_model** → 需补。
- 前端模型列表:`api.getModelsMarketplace()`(`/api/models/marketplace`)已有,可作下拉数据源。
- `/v1/models` 需鉴权(401 直取不行),故用 marketplace 接口。
## 三层改动设计
### 层1 前端 `src/web/pages/DigitalHuman.tsx`
- 加 state `llmModel`(默认空)+ draft 持久化。
- 加载时调 `api.getModelsMarketplace()` 取模型,过滤掉生图/视频模型(名称含
image/video/dall/flux/sd/wan/seedance/kling/banana 等,或按 marketplace 项的类型字段),
得文本模型列表 state `llmModels`。
- 加「脚本模型(可选)」`<select>`:空选项「默认(deepseek-v4-flash)」+ 文本模型选项。
- 提交 payload 加 `llm_model: llmModel || undefined`。
### 层2 后端 `src/routes/digital_human.rs`
- `pixelle_request_body` 加(照搬 tts_workflow 转发模式):
```rust
if let Some(value) = optional_string(body, &["llmModel", "llm_model"]) {
request_body["llm_model"] = json!(value);
}
```
### 层3 Pixelle `pixelle_video/pipelines/asset_based.py`
- 脚本生成调用前取 `llm_model = context.request.get("llm_model")`,非空则传入:
`self.core.llm(prompt=prompt, response_type=VideoScript, temperature=0.8, max_tokens=4000, model=llm_model)`。
(llm() 已接受 model 参数;None 时走 config 默认。)
- 容器内改 + 备份到 `/opt/Pixelle-Video/patches/`。
## 验证
- 前端 `npm run typecheck:web` 干净。
- 后端 `cargo build -j 2` 通过。
- Pixelle 语法校验 + 重启 healthy。
- 端到端:数字人任务选非默认脚本模型,Pixelle 日志出现 `LLM call: model=<所选模型>`。
## 非目标
- 不改生图模型选择(已由图片工作流下拉覆盖)。
- 不做参考图上传(功能 B,留后续)。
- 不动 comfy-cloud 适配器、LTX2.3 工作流。
## 提交约定
分支 `feat/comfy-cloud-adapter`。前端、后端分别提交;Pixelle 容器内改 + 备份(非本仓库)。中文 commit。
@@ -1,93 +0,0 @@
# LTX2.3 数字人工作流(Comfy Cloud)设计
日期:2026-06-18
分支:`feat/comfy-cloud-adapter`
状态:已通过 brainstorming,待落实现计划
## 背景
Pixelle 数字人(说话头像)能力原先只有 RunningHub 形态(`runninghub/digital_*.json` 是
`{"source":"runninghub","workflow_id":"..."}` 指针,非 ComfyUI 节点图,无法在 Comfy Cloud 跑)。
为让数字人走 Comfy Cloud,采用官方 ComfyUI 模板 `video_ltx2_3_id_lora`(LTX2.3 身份保持
LoRA + 音频驱动),把它做成 Pixelle 的 `comfy/` 工作流,经已建好的 comfy-cloud 适配器执行。
可行性已验证(查 Comfy Cloud `/object_info` 3548 节点 + 模型池):
- 模型齐全:`ltx-2.3-22b-dev-fp8`、`ltx-2.3-id-lora-talkvid-3k`、`gemma_3_12B_it_fp4_mixed`、
distilled LoRA 均托管;另有 `ltx-2.3-22b-ic-lora-lipdub-0.9` 口型 LoRA。
- 节点齐全:LTX2.3 音视频节点(LTXVReferenceAudio/LTXVConcatAVLatent/LTXVAudioVAEDecode 等)
全在 Comfy Cloud 节点集中。
- 模板 I/O = LoadImage(肖像)+ LoadAudio(旁白)+ 文本 → SaveVideo(带音频说话视频),
正好匹配数字人。
## 已确认决策
1. 用官方模板 `video_ltx2_3_id_lora`(LTX2.3),不自拼 InfiniteTalk(官方模板天然 Comfy-Cloud
兼容、模型已托管、风险低)。
2. 接入方式:comfy 通道 + 节点图注入(放 `comfy/digital_ltx2.json`,经适配器),不走 RunningHub、
不另做独立入口。
3. comfykit 注入机制:节点 `_meta.title` 标记(`$~image!` / `$~audio!` / `$prompt!`)。
4. 分支 `feat/comfy-cloud-adapter`(与 comfy-cloud 适配器同分支,属同一能力线)。
## 前置依赖(硬依赖)
**需要用户从 Comfy Cloud 导出 API 格式 JSON**:模板当前是 UI 格式(含 subgraph 子图),
`/api/prompt` 要求 API 格式(`{node_id:{class_type,inputs,_meta}}`)。子图必须展开。
用户操作:Comfy Cloud 网页打开模板/share → Workflow → Export (API Format) → 放服务器
(如 `/tmp/ltx2_api.json`)。服务端无法代取(需登录态)。
## Pixelle media.py 改动(writing-plans 阶段发现的必需项)
现状:`pixelle_video/services/media.py` 的 `__call__` 只把 `image_path` 转发给 `api/` 通道
(`api_media`),**comfy/ 通道的 workflow_params 不含 image_path**。`audio_path` 经 `**params`
能进 workflow_params,但肖像图进不去。所以 comfy/ 工作流收不到肖像图。
改动:在 `media.py` 的非 api/ 分支(构建 `workflow_params` 处),把 `image_path` 也加入
`workflow_params`(命名 `image_path`),使其能被 `$~image_path!` 标记注入。一行级小改,
但属 Pixelle 源码(非 BoosAPI 仓库),改在容器内 `/app/pixelle_video/services/media.py`,
并同步到宿主持久化(确认 media.py 是否在挂载卷;若在镜像内则需固化方案)。
## 架构
```
Pixelle 数字人流程
生成肖像图(image_path)+ TTS 旁白(audio_path)+ 脚本文本
│ comfykit 按 _meta.title 标记注入到 LoadImage/LoadAudio/文本节点
▼
comfy/digital_ltx2.json (API 格式 + 标记)
│ comfyui_url = http://<boosapi>/comfy-cloud
▼
Comfy Cloud /api/prompt → LTX2.3(id-lora-talkvid + 可选 lipdub)→ 带音频说话视频
▼
SaveVideo 产出 → 适配器 /view 取回 → Pixelle HTML 模板合成
```
## 工作流改造(实现步骤)
基于用户导出的 API-format JSON:
1. `LoadImage` 节点 → `_meta.title = "$~image_path!"`(必填、媒体上传;键名对齐 media.py 转发的 image_path)
2. `LoadAudio` 节点 → `_meta.title = "$~audio_path!"`(键名对齐 api_video_params 注入的 audio_path)
3. 文本提示节点(PrimitiveStringMultiline / CLIPTextEncode)→ `_meta.title = "$prompt!"`(对齐 media.py 的 prompt)
4. `SaveVideo` 已知输出节点,自动识别,不标记
5. 删除 `RecordAudio` 节点(UI 录音交互,服务端不需要)
6. 成品写 `data/workflows/comfy/digital_ltx2.json`(宿主持久化,经 `./data` 挂载)
7. 键名以 media.py 实际放入 workflow_params 的为准(image_path/audio_path/prompt),已在 writing-plans 阶段确认。
## 验证
- 校验探测:`POST /comfy-cloud/prompt`(body 为改造后 `{prompt:节点图}`)→ 期望
`node_errors:{}` + 返回 prompt_id(不跑完整数字人流程,先验证节点+模型全兼容)。
- 真实验证:Pixelle 数字人任务选 `comfy/digital_ltx2.json`,出片确认。
- 错误处理沿用适配器:节点不支持→400 透传;模型缺失→校验错误透传;执行失败→job failed。
## 非目标
- 不改 comfy-cloud 适配器代码(已完成且验证)。
- 不做多人对话(multi-speaker)数字人,先单人肖像。
- 不在 Comfy Cloud 上自训/上传新模型,只用已托管模型。
- 不做 RunningHub 数字人的迁移/替换(保留并存)。
## 提交约定
分支 `feat/comfy-cloud-adapter`,工作流文件 + 任何 Pixelle 侧配置分开提交,中文 commit。
注:工作流文件落在 Pixelle 宿主目录(`/opt/Pixelle-Video/data/workflows/comfy/`),不在
BoosAPI 仓库内;若需纳入版本管理另议。
@@ -1,90 +0,0 @@
# Pixelle 连接凭证纳入 BoosAPI 管理 设计
日期:2026-06-18
分支:`feat/comfy-cloud-adapter`
状态:已通过 brainstorming,待用户复审
## 背景
Pixelle 的连接凭证(RunningHub API key、Comfy Cloud url/key)现在只能手动编辑
`/opt/Pixelle-Video/config.yaml` 再重启容器,很麻烦。本功能把这 3 个凭证纳入 BoosAPI
管理控制台,在 UI 里改、一键写入 Pixelle config 并重启生效。
## 已确认决策
1. 范围:只管 Pixelle 的 3 个连接凭证 —— `runninghub_api_key`、`comfyui_url`、`comfyui_api_key`。
不做整份 config.yaml 编辑,不做通用外部连接管理。
2. 同步机制:写文件 + 重启容器(BoosAPI 直接改 bind-mount 的 config.yaml,再 restart pixelle 容器)。
不给 Pixelle 加 HTTP 配置端点(避免改 Pixelle 源码)。
3. 真相源:BoosAPI settings 表;config.yaml 是被同步的目标。
4. 放置:连接管理页(`/accounts`,`src/web/pages/Accounts.tsx`)作为一个卡片/分区。
## 集成点(调查结论)
- Pixelle 无配置更新 HTTP 端点;`config.yaml` 是 bind-mount(`/opt/Pixelle-Video/config.yaml`),
BoosAPI 跑在宿主,有读写权限。
- 凭证在 config.yaml 的 `comfyui:` 段:`runninghub_api_key` / `comfyui_url` / `comfyui_api_key`。
- 改后 Pixelle 需重启容器生效(`cd /opt/Pixelle-Video && docker compose restart api`)。
- BoosAPI 有 settings 表(key-value)+ builtin_key 掩码范式可复用。
## 架构
```
连接管理页「Pixelle 连接」卡片(3 输入框 + 保存)
→ PUT /api/admin/pixelle/connection
1. 写 settings 表(pixelle.runninghub_api_key 等)
2. 备份 config.yaml → config.yaml.bak-<ts>
3. YAML 改 comfyui.{runninghub_api_key,comfyui_url,comfyui_api_key} 三键(其余保留)
4. docker compose restart api(cwd=/opt/Pixelle-Video)
→ GET /api/admin/pixelle/connection 返回当前值(密钥掩码)
```
## 三层改动设计
### 层1 后端 service `src/services/pixelle_connection.rs`(新建)
- `struct PixelleConnection { runninghub_api_key, comfyui_url, comfyui_api_key }`(均 Option<String>)。
- `read_connection(db) -> PixelleConnection`:从 settings 表读 `pixelle.*` 三键。
- `save_connection(db, conn)`:三键写 settings 表(UPSERT)。
- `sync_to_pixelle(config_path) -> AppResult<()>`:读 yaml → 改 comfyui 三键 → 备份 → 写回。
用 `serde_yaml`(确认 Cargo.toml 有;无则加)。只改这三键,其余键原样保留(读成
`serde_yaml::Value` 再定点改,不整体重序列化丢注释——注意:serde_yaml 会丢注释,
接受此代价,或保留备份)。
- `restart_pixelle(compose_dir) -> AppResult<()>`:`docker compose restart api`,cwd=compose_dir。
- 配置项:Pixelle config 路径 + compose 目录,默认 `/opt/Pixelle-Video/config.yaml`、
`/opt/Pixelle-Video`,从 BoosAPI config(新增 `[pixelle_video] config_path` / 复用现有)读。
### 层2 后端路由 `src/routes/admin/pixelle_connection.rs`(新建)+ 挂载
- `GET /api/admin/pixelle/connection`:返回三值,api_key 掩码(`••••<last4>`)。
- `PUT /api/admin/pixelle/connection`:收三字段 → save_connection → sync_to_pixelle → restart。
掩码值(`••••`)表示「不改」,跳过该字段。
- 挂到 admin router(走现有 require_admin 中间件)。
### 层3 前端 `src/web/pages/Accounts.tsx` + `api.ts`
- `api.ts` adminApi 加 `getPixelleConnection()` / `savePixelleConnection(data)`。
- Accounts 页加「Pixelle 连接」卡片:3 输入框(runninghub key、comfy url、comfy key)+ 保存。
加载时拉当前值(掩码),保存提示「已保存并重启 Pixelle,约数秒生效」。
## 错误处理
- config.yaml 不存在/无写权限 → 503 + 明确提示,**不重启**。
- docker restart 失败 → 返回错误,提示「配置已写入,但 Pixelle 重启失败,请手动重启」。
- 写文件前必备份;写失败保留原文件。
## 验证
- 后端:rstest 用临时 yaml 测 sync_to_pixelle(读→改3键→保留其余→备份生成);
`cargo build -j 2`。
- 前端:`npm run typecheck:web`。
- docker restart 部分手动验证(端到端:UI 改 runninghub key → 确认 config.yaml 变 + 容器重启)。
## 非目标
- 不做整份 config.yaml 编辑。
- 不做通用「外部服务连接」管理(只 Pixelle)。
- 不给 Pixelle 加源码/端点。
- 不动 comfy-cloud 适配器、数字人工作流。
## 提交约定
分支 `feat/comfy-cloud-adapter`。service / 路由 / 前端 分开提交,中文 commit。
serde_yaml 若需新增依赖,单独说明。
@@ -1,58 +0,0 @@
# workflow_ui/mod.rs 续拆(6 子模块)设计
日期:2026-06-18
分支:`feat/comfy-cloud-adapter`
状态:已通过 brainstorming,待用户复审
## 背景
上一轮已把 `workflow_ui.rs`(6181 行)拆成 8 模块,但 `mod.rs` 仍 4095 行——一批
未在上轮清单内的函数(native proxy / repair 链 / bootstrap 注入 / run 转换 / ws / 模型注入)
留在了 mod.rs。本轮继续把这 6 组抽出,让 mod.rs 瘦到 ~1000-1200 行(只剩 routes()
+ 共享 struct + 小工具)。
延续上轮纪律:**纯机械移动,函数体一字不改**,只调可见性 + use,编译器 + 5 测试验证。
## 已确认决策
1. 续拆 workflow_ui/mod.rs 残留。
2. 拆成 6 子模块(见下表)。
3. 纯机械移动,零逻辑改动。
4. 安全:每抽一组即 cargo build -j 2 + 5 测试保持;末尾各文件行数核对。
## 子模块划分
| 子模块 | 函数组 |
|--------|--------|
| `model_inject.rs` | comfy_combo_input, inject_model_choices, is_boosapi_extension_node_type, workflow_node_defs_with_models, inject_boosapi_object_info, builtin_model_folders, inject_boosapi_model_folders, workflow_model_folder_payload, native_model_folder_payload_or_fallback, model_payload_items_from_names, normalize_model_item_name, workflow_model_display_aliases, model_payload_item_with_name, merge_model_payload_items, normalize_model_folder_payload |
| `repair.rs` | inject_native_prompt_cloud_api_key, repair_resize_image_mask_nodes(+_without_workflow), repair_text_generate_ltx2_prompt_nodes, repair_dynamic_combo_nodes(+ dynamic_combo_* 全部 helper), repair_wan2_api_nodes(+ wan2_* / is_wan2_* helper), collect_workflow_nodes, prompt_subgraph_node_id, id_to_prompt_key |
| `native_proxy.rs` | native_workflow_enabled/base/http_path/request/json/json_cached/response/view_response/video_response/file_response/raw_response/api_proxy, archive_native_workflow_asset, repair_native_prompt_body_value, workflow_cache_kind_from_key, workflow_cache_status, upload_image(+_to_local_backend/_to_native_backend), upload_mask, timestamp_ms |
| `run_convert.rs` | workflow_run_to_history/_task/_queue_task/_prompt_tuple, run_status_messages, run_outputs_for_comfy, node_output_for_comfy, comfy_media_items, normalize_comfy_media_item, workflow_text_output, workflow_graph_to_comfy_workflow, workflow_timestamp_ms |
| `ws.rs` | workflow_session_sender, publish_workflow_ws_event, workflow_node_label, workflow_progress_callback_with_run_sender, publish_run_events, workflow_ws, native_workflow_ws_bridge |
| `bootstrap.rs` | inject_bootstrap, workflow_secret_cleanup_bootstrap, inject_external_bootstrap, inject_enhanced_bootstrap |
> 留 mod.rs:routes()、共享 struct(WorkflowCaller/CachedJson/各 Query/Payload)、跨多模块小工具
> (safe_view_path/user_home/guess_mime_from_path/parse_boolish/auth_caller_optional/
> workflow_context/parse_workflow_cookie/build_query_string/simple_urlencode/各 workflow_widget_*
> validation helper 等)、experiment_models/view_metadata/custom_nodes_i18n/internal_* 等零散 handler。
> 实现时按实际依赖判定:被多组共用的留 mod.rs `pub(super)`。
## 安全策略
同上轮:每抽一组 → `cargo build -j 2`(编译器是主安全网,纯移动)→ `cargo test -p boosapi
--lib routes::workflow_ui`(5 测试基线不减)→ 提交。跨模块依赖的私有符号按编译报错逐个
改 `pub(super)`。子模块内部 helper 也 `pub(super)` 或保持模块私有(看是否跨文件用)。
## 测试
编译器逐组验证;5 个现有测试全程保持;末尾核对 8+6 个子文件行数,mod.rs 应 <1500。
## 非目标
- 不改逻辑、不改路由、不动 handler 行为。
- 不拆其他巨文件(oauth/payment/digital_human 各自后续循环)。
- 不顺手优化函数实现。
## 提交约定
分支 `feat/comfy-cloud-adapter`。每抽 1 组 + 编译通过即提交。中文 commit。
@@ -1,69 +0,0 @@
# workflow_ui.rs 拆分(8 子模块)设计
日期:2026-06-18
分支:`feat/comfy-cloud-adapter`
状态:已通过 brainstorming,待用户复审
## 背景
`src/routes/workflow_ui.rs` 单文件 6181 行,是项目最大文件,难维护/难测/edit 易错。
按职责拆成 `src/routes/workflow_ui/` 模块目录(8 子模块)。纯机械移动、零逻辑改动,
靠 Rust 编译器 + 现有 5 个测试验证。
## 已确认决策
1. 第一个优化目标:拆 workflow_ui.rs(项目最大文件)。
2. 拆成 8 子模块(见下表)。
3. 纯机械移动:函数体一字不改,只调可见性 + use 路径。零逻辑改动。
4. 安全策略:每抽一个子模块即 cargo build -j 2 验证;5 个测试全程保持通过;末尾冒烟。
## 子模块划分
| 子模块 | 职责 | 现有行号(约) |
|--------|------|--------------|
| `mod.rs` | routes() 装配 + 共享类型(WorkflowCaller、Query/Payload structs)+ 子模块声明 | 56-124 |
| `assets.rs` | UI 静态资源服务、嵌入目录解析、index/static/serve_file/官方模板资源 | 211-305, 1533-1814 |
| `auth.rs` | caller 解析(caller_from_headers/auth_caller/caller_user_id/caller_is_admin) | 313-363, 1875 |
| `node_defs.rs` | 节点定义 + 工作流模板(core_node_definitions、*_workflow_graph、node_type_def_to_comfy) | 429-932 |
| `models.rs` | 模型分类 + folder 模型(is_*_workflow_model、workflow_folder_*、native_folder_models) | 939-1530 |
| `comfy_proxy.rs` | ComfyUI API 代理 handler(settings/userdata/object_info/system_stats/extensions 等) | 1883-2200 |
| `validate.rs` | 工作流校验(validate_workflow* 三个) | 2206-2586 |
| `queue.rs` | 队列/历史/prompt(queue/history/queue_prompt/repair_native_prompt_payload/queue_boosapi_prompt/view) | 2586-3679 |
> 注:行号是当前快照,实现时以函数名定位为准(文件会随抽取缩短)。各子模块的辅助小函数
> 随其主要消费者走;跨多模块共用的(如 guess_mime_from_path、parse_boolish、safe_view_path)
> 放 mod.rs 或新建 util.rs(实现时按实际依赖定,优先放 mod.rs 顶部 pub(super))。
## 安全策略(核心)
1. **纯移动**:剪切函数到子模块,函数体一字不改。仅:
- 可见性:跨模块调用的改 `pub(super)` / `pub(crate)`;
- `use`:子模块顶部补齐它用到的依赖路径(crate::、super::、外部 crate)。
2. **逐模块验证**:每抽一个子模块就 `cargo build -j 2`。Rust 编译器对未解析引用/类型不符
直接报错——这是纯移动重构的强安全网。
3. **测试保持**:现有 5 个测试(在文件尾 `#[cfg(test)] mod tests`)迁到相关子模块(多与
models/node_defs 相关),全程保持通过:`cargo test -p boosapi --lib routes::workflow_ui`。
4. **末尾冒烟**:全部完成后,工作流 UI 打开 + 跑一个 prompt,确认行为不变。
## 数据流
不变。`routes/mod.rs` 仍 `.merge(workflow_ui::routes())`,对外接口零变化。仅文件内部
组织从 1 文件变 8 文件,所有路由路径、handler 行为、类型签名完全一致。
## 测试
- 编译器:每步 cargo build -j 2(主验证手段)。
- 单测:迁移后 5 个测试保持通过。
- 冒烟:手动开工作流 UI + 跑 prompt。
- 不新增功能测试(本任务是重构,非加功能;但若迁移中发现某函数明显缺测且易加,可顺手补)。
## 非目标
- 不改任何逻辑、不改路由路径、不改 handler 行为。
- 不拆其他巨文件(oauth.rs/digital_human.rs/user/mod.rs 各自独立后续循环)。
- 不顺手重构/优化函数实现(纯搬运)。
## 提交约定
分支 `feat/comfy-cloud-adapter`。**按子模块分多次提交**(每抽 1-2 个子模块 + 编译通过即提交),
便于回溯;不一锅烩。中文 commit。
@@ -0,0 +1 @@
ALTER TABLE agent_runs ADD COLUMN artifacts_json TEXT;
@@ -0,0 +1 @@
ALTER TABLE `agent_runs` ADD COLUMN `artifacts_json` LONGTEXT NULL;
@@ -0,0 +1 @@
ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS artifacts_json TEXT;
-12129
View File
File diff suppressed because it is too large Load Diff
-260
View File
@@ -1,260 +0,0 @@
#!/usr/bin/env bash
# Build the BossAI production binary, package Alpine/musl runtime libraries,
# and deploy the runnable bundle to a Debian server.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
REMOTE_HOST="${BOSSAI_DEBIAN_HOST:-129.211.30.104}"
REMOTE_USER="${BOSSAI_DEBIAN_USER:-root}"
REMOTE_DIR="${BOSSAI_DEBIAN_DIR:-/root/BossAI}"
SSH_KEY="${BOSSAI_DEBIAN_SSH_KEY:-$HOME/.ssh/id_rsa_mbp}"
BOSSAI_SOURCE_DIR="${BOSSAI_SOURCE_DIR:-/root/BossAI-build/main}"
BOSSAI_REMOTE="${BOSSAI_REMOTE:-bossai}"
BOSSAI_REF="${BOSSAI_REF:-main}"
BOSSAI_WORKFLOW_UI_SOURCE="${BOSSAI_WORKFLOW_UI_SOURCE:-$ROOT_DIR/vendor/ComfyUI_frontend}"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
SKIP_BUILD=0
SKIP_VERIFY=0
SKIP_SOURCE_UPDATE=0
usage() {
cat <<'EOF'
Usage: scripts/deploy-bossai-debian.sh [options]
Build BossAI from the BossAI source tree, bundle the musl runtime libraries
needed by the Alpine-built binary, upload it to a Debian host, extract it,
and run a config check.
Options:
--host <host> Debian host/IP. Default: 129.211.30.104
--user <user> SSH user. Default: root
--dir <remote-dir> Remote deploy directory. Default: /root/BossAI
--key <ssh-key> SSH private key. Default: ~/.ssh/id_rsa_mbp
--source-dir <dir> BossAI source worktree. Default: /root/BossAI-build/main
--remote <name> Git remote for BossAI. Default: bossai
--ref <ref> BossAI branch/ref to build. Default: main
--skip-build Reuse dist/bossai-production from a previous build
--skip-source-update Do not fetch/reset the BossAI source worktree
--skip-verify Skip remote ./run-boosapi.sh --check-config
-h, --help Show this help
Environment overrides:
BOSSAI_DEBIAN_HOST
BOSSAI_DEBIAN_USER
BOSSAI_DEBIAN_DIR
BOSSAI_DEBIAN_SSH_KEY
BOSSAI_SOURCE_DIR
BOSSAI_REMOTE
BOSSAI_REF
BOSSAI_WORKFLOW_UI_SOURCE
BOSSAI_PRODUCTION_DIR
BOSSAI_DEBIAN_BUNDLE_DIR
BOSSAI_DEBIAN_BUNDLE_ARCHIVE
BOOSAPI_FRONTEND_PM auto / bun / npm, forwarded to build-and-run.sh
Remote start command after deploy:
cd /root/BossAI && ./run-boosapi.sh
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--host)
shift
[ $# -gt 0 ] || fail "--host requires a value"
REMOTE_HOST="$1"
;;
--user)
shift
[ $# -gt 0 ] || fail "--user requires a value"
REMOTE_USER="$1"
;;
--dir)
shift
[ $# -gt 0 ] || fail "--dir requires a value"
REMOTE_DIR="$1"
;;
--key)
shift
[ $# -gt 0 ] || fail "--key requires a value"
SSH_KEY="$1"
;;
--source-dir)
shift
[ $# -gt 0 ] || fail "--source-dir requires a value"
BOSSAI_SOURCE_DIR="$1"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
;;
--remote)
shift
[ $# -gt 0 ] || fail "--remote requires a value"
BOSSAI_REMOTE="$1"
;;
--ref)
shift
[ $# -gt 0 ] || fail "--ref requires a value"
BOSSAI_REF="$1"
;;
--skip-build)
SKIP_BUILD=1
;;
--skip-source-update)
SKIP_SOURCE_UPDATE=1
;;
--skip-verify)
SKIP_VERIFY=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
require_file() {
local path="$1"
[ -f "$path" ] || fail "Missing required file: $path"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
ensure_bossai_source() {
require_command git
if [ "$SKIP_SOURCE_UPDATE" -eq 1 ]; then
log "Skipping BossAI source update; using $BOSSAI_SOURCE_DIR"
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
return 0
fi
log "Preparing BossAI source from $BOSSAI_REMOTE/$BOSSAI_REF → $BOSSAI_SOURCE_DIR"
git fetch "$BOSSAI_REMOTE" "$BOSSAI_REF"
if [ -d "$BOSSAI_SOURCE_DIR" ] && git -C "$BOSSAI_SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git -C "$BOSSAI_SOURCE_DIR" reset --hard "$BOSSAI_REMOTE/$BOSSAI_REF"
git -C "$BOSSAI_SOURCE_DIR" clean -fd
else
rm -rf "$BOSSAI_SOURCE_DIR"
mkdir -p "$(dirname "$BOSSAI_SOURCE_DIR")"
git worktree add "$BOSSAI_SOURCE_DIR" "$BOSSAI_REMOTE/$BOSSAI_REF"
fi
require_file "$BOSSAI_SOURCE_DIR/build-and-run.sh"
ok "BossAI source ready: $(git -C "$BOSSAI_SOURCE_DIR" rev-parse --short HEAD)"
}
prepare_workflow_ui_source() {
if [ -n "${BOOSAPI_WORKFLOW_UI_REPO:-}" ]; then
log "Using BOOSAPI_WORKFLOW_UI_REPO=$BOOSAPI_WORKFLOW_UI_REPO"
return 0
fi
if [ ! -d "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ] && [ ! -f "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ]; then
log "No local Workflow UI source at $BOSSAI_WORKFLOW_UI_SOURCE; install script may fetch from GitHub"
return 0
fi
log "Using local ComfyUI_frontend source: $BOSSAI_WORKFLOW_UI_SOURCE"
export BOOSAPI_WORKFLOW_UI_REPO="$BOSSAI_WORKFLOW_UI_SOURCE"
}
runtime_lib() {
local path="$1"
require_file "$path"
cp "$path" "$BUNDLE_DIR/lib/"
}
write_runner() {
cat > "$BUNDLE_DIR/run-boosapi.sh" <<'EOF'
#!/usr/bin/env sh
set -eu
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$DIR/lib/ld-musl-x86_64.so.1" --library-path "$DIR/lib" "$DIR/boosapi" "$@"
EOF
chmod +x "$BUNDLE_DIR/run-boosapi.sh"
}
package_bundle() {
require_file "$PRODUCTION_DIR/boosapi"
require_file "$PRODUCTION_DIR/config.toml"
rm -rf "$BUNDLE_DIR" "$BUNDLE_ARCHIVE"
mkdir -p "$BUNDLE_DIR/lib"
cp "$PRODUCTION_DIR/boosapi" "$BUNDLE_DIR/boosapi"
cp "$PRODUCTION_DIR/config.toml" "$BUNDLE_DIR/config.toml"
chmod +x "$BUNDLE_DIR/boosapi"
runtime_lib /lib/ld-musl-x86_64.so.1
runtime_lib /usr/lib/libssl.so.3
runtime_lib /usr/lib/libcrypto.so.3
runtime_lib /usr/lib/libgcc_s.so.1
write_runner
tar -C "$BUNDLE_DIR" -czf "$BUNDLE_ARCHIVE" .
ok "Bundle ready: $BUNDLE_ARCHIVE"
sha256sum "$BUNDLE_ARCHIVE" "$BUNDLE_DIR/boosapi"
ls -lh "$BUNDLE_ARCHIVE"
}
deploy_bundle() {
require_file "$SSH_KEY"
require_command ssh
require_command scp
local remote="${REMOTE_USER}@${REMOTE_HOST}"
local ssh_opts=(-i "$SSH_KEY" -o StrictHostKeyChecking=accept-new)
log "Preparing remote directory: $remote:$REMOTE_DIR"
ssh "${ssh_opts[@]}" "$remote" "mkdir -p '$REMOTE_DIR'"
log "Uploading bundle..."
scp "${ssh_opts[@]}" "$BUNDLE_ARCHIVE" "$remote:$REMOTE_DIR/bossai-debian-bundle.tar.gz"
log "Extracting bundle on Debian host..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && tar -xzf bossai-debian-bundle.tar.gz && chmod +x boosapi run-boosapi.sh && sha256sum bossai-debian-bundle.tar.gz boosapi"
if [ "$SKIP_VERIFY" -eq 0 ]; then
log "Checking remote config..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && ./run-boosapi.sh --check-config"
fi
ok "Deployed BossAI to $remote:$REMOTE_DIR"
log "Start with: cd $REMOTE_DIR && ./run-boosapi.sh"
}
if [ "$SKIP_BUILD" -eq 0 ]; then
ensure_bossai_source
prepare_workflow_ui_source
log "Building BossAI production bundle..."
BOOSAPI_FRONTEND_PM="$FRONTEND_PM" "$BOSSAI_SOURCE_DIR/build-and-run.sh" \
--production \
--production-dir "$PRODUCTION_DIR"
else
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
log "Skipping build; using existing $PRODUCTION_DIR"
fi
package_bundle
deploy_bundle
+180 -2
View File
@@ -24,6 +24,7 @@
use crate::auth::jwt::validate_jwt; use crate::auth::jwt::validate_jwt;
use crate::services::agent_workbench::{ use crate::services::agent_workbench::{
bash_sandbox::SandboxedBashTool, bash_sandbox::SandboxedBashTool,
document::{output_mime, CreateDocumentTool},
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode}, permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
run_registry, sandbox, run_registry, sandbox,
session_store::{self, CancelRunOutcome, ClaimRunError, RunTerminal, UsageDelta}, session_store::{self, CancelRunOutcome, ClaimRunError, RunTerminal, UsageDelta},
@@ -68,8 +69,9 @@ const SYSTEM_PROMPT: &str =
"You are BoosAPI's built-in engineering agent. You can read and write files \ "You are BoosAPI's built-in engineering agent. You can read and write files \
and run shell commands, but only inside the current working directory, which is an \ and run shell commands, but only inside the current working directory, which is an \
isolated sandbox for this session. Be concise. Explain what you are about to do before \ isolated sandbox for this session. Be concise. Explain what you are about to do before \
using a tool that writes files or runs commands. Never attempt to access paths outside \ using a tool that writes files or runs commands. Use CreateDocument when the user asks \
the working directory."; for a downloadable document, spreadsheet, presentation, PDF, or structured text file. \
Never attempt to access paths outside the working directory.";
static INTERACTIONS: Lazy<InteractionBroker> = static INTERACTIONS: Lazy<InteractionBroker> =
Lazy::new(|| InteractionBroker::new(Duration::from_secs(300))); Lazy::new(|| InteractionBroker::new(Duration::from_secs(300)));
@@ -80,6 +82,7 @@ pub fn routes() -> Router<AppState> {
.route("/api/agent/attachments", post(upload_attachment_handler)) .route("/api/agent/attachments", post(upload_attachment_handler))
.route("/api/agent/approve", post(approve_handler)) .route("/api/agent/approve", post(approve_handler))
.route("/api/agent/answer", post(answer_handler)) .route("/api/agent/answer", post(answer_handler))
.route("/api/agent/runs/{run_id}", get(get_run_handler))
.route("/api/agent/runs/{run_id}/cancel", post(cancel_run_handler)) .route("/api/agent/runs/{run_id}/cancel", post(cancel_run_handler))
.route("/api/agent/models", get(models_handler)) .route("/api/agent/models", get(models_handler))
.route("/api/agent/sessions", get(list_sessions_handler)) .route("/api/agent/sessions", get(list_sessions_handler))
@@ -1125,6 +1128,15 @@ async fn chat_handler(
return Err(fail_claim(format!("failed to create session sandbox: {error}")).await) return Err(fail_claim(format!("failed to create session sandbox: {error}")).await)
} }
}; };
let outputs_dir = cwd.join("outputs");
if let Err(error) = tokio::fs::remove_dir_all(&outputs_dir).await {
if error.kind() != std::io::ErrorKind::NotFound {
return Err(fail_claim(format!("failed to reset Agent outputs: {error}")).await);
}
}
if let Err(error) = tokio::fs::create_dir_all(&outputs_dir).await {
return Err(fail_claim(format!("failed to create Agent outputs: {error}")).await);
}
let attachment_paths = let attachment_paths =
match materialize_attachments(&state.db, auth.user_id, &cwd, &body.attachments).await { match materialize_attachments(&state.db, auth.user_id, &cwd, &body.attachments).await {
@@ -1188,6 +1200,7 @@ async fn chat_handler(
}; };
let db = state.db.clone(); let db = state.db.clone();
let app_state = state.clone();
let user_id = auth.user_id; let user_id = auth.user_id;
let actor_key = auth.actor_key; let actor_key = auth.actor_key;
let agent_cancel = cancel.clone(); let agent_cancel = cancel.clone();
@@ -1256,6 +1269,9 @@ async fn chat_handler(
if tool_policy.allows("Bash") { if tool_policy.allows("Bash") {
custom_tools.push(Arc::new(SandboxedBashTool::new(cwd.clone(), mode))); custom_tools.push(Arc::new(SandboxedBashTool::new(cwd.clone(), mode)));
} }
if tool_policy.allows("CreateDocument") {
custom_tools.push(Arc::new(CreateDocumentTool));
}
let agent = Agent::new(AgentOptions { let agent = Agent::new(AgentOptions {
provider: Some(provider), provider: Some(provider),
@@ -1361,6 +1377,36 @@ async fn chat_handler(
} }
} }
if failure.is_none() && !agent_cancel.is_cancelled() && latest_transcript.is_some() {
match archive_agent_outputs(
&app_state,
&cwd,
user_id,
&session_id,
&run_id,
&model,
)
.await
{
Ok(artifacts) if !artifacts.is_empty() => {
match serde_json::to_string(&artifacts) {
Ok(raw) => {
if let Err(error) =
session_store::save_run_artifacts(&db, &run_id, &raw).await
{
failure = Some(error.to_string());
} else {
result_data["artifacts"] = Value::Array(artifacts);
}
}
Err(error) => failure = Some(error.to_string()),
}
}
Ok(_) => {}
Err(error) => failure = Some(error),
}
}
if failure.is_none() && !agent_cancel.is_cancelled() { if failure.is_none() && !agent_cancel.is_cancelled() {
if let Some(messages) = latest_transcript.as_ref() { if let Some(messages) = latest_transcript.as_ref() {
match context_codec.encode_compacted(messages) { match context_codec.encode_compacted(messages) {
@@ -1627,6 +1673,102 @@ async fn forward_sdk_event(
Ok(()) Ok(())
} }
async fn archive_agent_outputs(
state: &AppState,
cwd: &std::path::Path,
user_id: Option<i64>,
session_id: &str,
run_id: &str,
model: &str,
) -> Result<Vec<Value>, String> {
const MAX_OUTPUT_FILES: usize = 24;
let output_dir = cwd.join("outputs");
let mut entries = tokio::fs::read_dir(&output_dir)
.await
.map_err(|error| format!("failed to read Agent outputs: {error}"))?;
let mut files = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|error| format!("failed to enumerate Agent outputs: {error}"))?
{
let metadata = tokio::fs::symlink_metadata(entry.path())
.await
.map_err(|error| format!("failed to inspect Agent output: {error}"))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
continue;
}
if output_mime(&entry.path()).is_some() {
files.push(entry.path());
}
}
files.sort();
if files.len() > MAX_OUTPUT_FILES {
return Err(format!("Agent produced more than {MAX_OUTPUT_FILES} output files"));
}
let mut artifacts = Vec::with_capacity(files.len());
for path in files {
let mime_type = output_mime(&path)
.ok_or_else(|| "unsupported Agent output format".to_string())?;
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| "Agent output has an invalid file name".to_string())?
.to_string();
let bytes = tokio::fs::read(&path)
.await
.map_err(|error| format!("failed to read Agent output '{file_name}': {error}"))?;
if bytes.len() > agent_attachments::MAX_ATTACHMENT_BYTES {
return Err(format!("Agent output '{file_name}' exceeds 64 MB"));
}
let asset = workspace::record_bytes_asset(
state,
workspace::WorkspaceAssetInput {
user_id,
source: "agent-output".to_string(),
kind: "document".to_string(),
title: Some(file_name.clone()),
prompt: None,
model: Some(model.to_string()),
remote_url: None,
metadata: json!({
"workspaceAsset": { "role": "output" },
"agentSessionId": session_id,
"agentRunId": run_id,
"originalFileName": file_name,
}),
},
&bytes,
mime_type,
)
.await
.map_err(|error| format!("failed to archive Agent output '{file_name}': {error}"))?;
crate::db::sql::query(
"UPDATE workspace_assets SET file_name = ?, title = ? WHERE public_id = ?",
)
.bind(&file_name)
.bind(&file_name)
.bind(&asset.public_id)
.execute(&*state.db)
.await
.map_err(|error| format!("failed to name Agent output asset: {error}"))?;
let file_url = if user_id.is_some() {
format!("/api/user/workspace/assets/{}/file", asset.public_id)
} else {
format!("/api/workspace/assets/{}/file", asset.public_id)
};
artifacts.push(json!({
"publicId": asset.public_id,
"fileName": file_name,
"mimeType": mime_type,
"sizeBytes": bytes.len(),
"fileUrl": file_url,
}));
}
Ok(artifacts)
}
async fn agent_run_ledger_cost(db: &sqlx::AnyPool, run_id: &str) -> Result<Option<f64>, String> { async fn agent_run_ledger_cost(db: &sqlx::AnyPool, run_id: &str) -> Result<Option<f64>, String> {
crate::db::sql::query_scalar::<_, Option<f64>>( crate::db::sql::query_scalar::<_, Option<f64>>(
"SELECT SUM(COALESCE(estimated_cost, 0)) "SELECT SUM(COALESCE(estimated_cost, 0))
@@ -1771,6 +1913,36 @@ async fn cancel_run_handler(
} }
} }
async fn get_run_handler(
State(state): State<AppState>,
headers: HeaderMap,
Path(run_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let auth = resolve_auth(&headers, &state).await?;
let run = session_store::get_run(&state.db, &run_id, &auth.actor_key)
.await
.map_err(internal_db_error)?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "ok": false, "error": "Agent run not found" })),
)
})?;
let artifacts = run
.artifacts_json
.as_deref()
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
.unwrap_or_else(|| json!([]));
let binding = InteractionBinding::new(&auth.actor_key, &run.session_id, &run.id);
let interactions = INTERACTIONS.pending_for_binding(&binding).await;
Ok(Json(json!({
"ok": true,
"run": run,
"interactions": interactions,
"artifacts": artifacts,
})))
}
async fn list_sessions_handler( async fn list_sessions_handler(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -1818,10 +1990,16 @@ async fn get_session_handler(
})? })?
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok()) .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.unwrap_or_else(|| json!([])); .unwrap_or_else(|| json!([]));
let artifacts = session_store::latest_session_artifacts(&state.db, &id, auth.user_id)
.await
.map_err(internal_db_error)?
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.unwrap_or_else(|| json!([]));
Ok(Json(json!({ Ok(Json(json!({
"ok": true, "ok": true,
"session": session, "session": session,
"transcript": transcript, "transcript": transcript,
"artifacts": artifacts,
}))) })))
} }
+35 -7
View File
@@ -23,8 +23,10 @@ use std::time::Duration;
const TEMPLATE_GALLERY_PAGE_URL: &str = const TEMPLATE_GALLERY_PAGE_URL: &str =
"https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html"; "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html";
const LINUX_DO_TEMPLATE_JSON: &str = const TEMPLATE_DATA_URL: &str =
include_str!("../../assets/picture-agent/linux_do_2036927_prompts_images.with_tags.json"); "https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.with_tags.json";
const TEMPLATE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const TEMPLATE_MAX_BYTES: usize = 16 * 1024 * 1024;
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation"; const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300; const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
const PICTURE_GENERATION_RETRY_SECS: u64 = 180; const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
@@ -435,10 +437,10 @@ async fn templates_handler(
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let _auth = resolve_picture_agent_auth(&headers, &state).await?; let _auth = resolve_picture_agent_auth(&headers, &state).await?;
let limit = query.limit.unwrap_or(144).clamp(1, 240); let limit = query.limit.unwrap_or(144).clamp(1, 240);
let (templates, local_status) = match parse_gallery_templates(LINUX_DO_TEMPLATE_JSON, limit) { let (templates, remote_status) = match fetch_gallery_templates(&state.http, limit).await {
Ok(templates) => (templates, json!({ "ok": true, "source": "local-json" })), Ok(templates) => (templates, json!({ "ok": true, "source": "remote-json" })),
Err(err) => { Err(err) => {
tracing::warn!(%err, "Picture Agent local Linux Do template gallery unavailable"); tracing::warn!(%err, "Picture Agent remote Linux Do template gallery unavailable");
(Vec::new(), json!({ "ok": false, "message": err })) (Vec::new(), json!({ "ok": false, "message": err }))
} }
}; };
@@ -446,17 +448,43 @@ async fn templates_handler(
Ok(Json(json!({ Ok(Json(json!({
"success": true, "success": true,
"sourceUrl": TEMPLATE_GALLERY_PAGE_URL, "sourceUrl": TEMPLATE_GALLERY_PAGE_URL,
"source": local_status, "source": remote_status,
"templates": templates, "templates": templates,
}))) })))
} }
async fn fetch_gallery_templates(
client: &reqwest::Client,
limit: usize,
) -> Result<Vec<Value>, String> {
let response = client
.get(TEMPLATE_DATA_URL)
.timeout(TEMPLATE_REQUEST_TIMEOUT)
.send()
.await
.map_err(|err| format!("failed to fetch remote Linux Do templates: {err}"))?
.error_for_status()
.map_err(|err| format!("remote Linux Do templates returned an error: {err}"))?;
let bytes = response
.bytes()
.await
.map_err(|err| format!("failed to read remote Linux Do templates: {err}"))?;
if bytes.len() > TEMPLATE_MAX_BYTES {
return Err(format!(
"remote Linux Do templates exceed {TEMPLATE_MAX_BYTES} bytes"
));
}
let raw = String::from_utf8(bytes.to_vec())
.map_err(|err| format!("remote Linux Do templates are not UTF-8: {err}"))?;
parse_gallery_templates(&raw, limit)
}
fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String> { fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String> {
if limit == 0 { if limit == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let items = serde_json::from_str::<Vec<GalleryItem>>(raw) let items = serde_json::from_str::<Vec<GalleryItem>>(raw)
.map_err(|err| format!("failed to parse local Linux Do templates: {err}"))?; .map_err(|err| format!("failed to parse remote Linux Do templates: {err}"))?;
Ok(items Ok(items
.into_iter() .into_iter()
+1 -1
View File
@@ -234,7 +234,7 @@ pub(super) async fn workflow_ui_index(State(state): State<AppState>) -> Html<Str
<div class="wrap"> <div class="wrap">
<div class="box"> <div class="box">
<h1>Workflow Studio 前端未构建</h1> <h1>Workflow Studio 前端未构建</h1>
<p>官方 ComfyUI 前端会按后端类型从 <code>dist/workflow-ui-external</code> 或 <code>dist/workflow-ui-cloud</code> 加载。请先运行 <code>npm run workflow-ui:install</code>,然后刷新此页面。</p> <p>官方 ComfyUI 前端会按后端类型从 <code>dist/workflow-ui-external</code> 或 <code>dist/workflow-ui-cloud</code> 加载。请先运行 <code>bun run workflow-ui:install</code>,然后刷新此页面。</p>
</div> </div>
</div> </div>
</body> </body>
+283
View File
@@ -0,0 +1,283 @@
use async_trait::async_trait;
use lopdf::{
content::{Content, Operation},
dictionary, Document, Object, Stream, StringFormat,
};
use office_oxide::DocumentFormat;
use open_agent_sdk::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
use serde_json::{json, Value};
use std::{collections::HashMap, io::Cursor, path::Path};
const MAX_DOCUMENT_CHARS: usize = 2_000_000;
pub struct CreateDocumentTool;
#[async_trait]
impl Tool for CreateDocumentTool {
fn name(&self) -> &str {
"CreateDocument"
}
fn description(&self) -> &str {
"Creates a downloadable document in the Agent outputs directory from Markdown content. Supports docx, xlsx, pptx, pdf, md, txt, csv, and json."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
("file_name".to_string(), json!({
"type": "string",
"description": "Output file name without a directory path"
})),
("format".to_string(), json!({
"type": "string",
"enum": ["docx", "xlsx", "pptx", "pdf", "md", "txt", "csv", "json"]
})),
("content".to_string(), json!({
"type": "string",
"description": "Markdown or plain-text document content"
})),
]),
required: vec!["file_name".to_string(), "format".to_string(), "content".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let requested_name = input
.get("file_name")
.and_then(Value::as_str)
.ok_or_else(|| ToolError::InvalidInput("Missing 'file_name' field".to_string()))?;
let format = input
.get("format")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
let content = input
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| ToolError::InvalidInput("Missing 'content' field".to_string()))?;
if content.chars().count() > MAX_DOCUMENT_CHARS {
return Err(ToolError::InvalidInput(format!(
"Document exceeds {MAX_DOCUMENT_CHARS} characters"
)));
}
let file_name = safe_file_name(requested_name, &format)?;
let output_dir = Path::new(&context.working_dir).join("outputs");
tokio::fs::create_dir_all(&output_dir)
.await
.map_err(ToolError::IoError)?;
let path = output_dir.join(&file_name);
let bytes = match format.as_str() {
"docx" | "xlsx" | "pptx" => create_office(content, &format).await?,
"pdf" => create_pdf(content).map_err(ToolError::IoError)?,
"md" | "txt" | "csv" | "json" => content.as_bytes().to_vec(),
_ => {
return Err(ToolError::InvalidInput(
"Unsupported document format".to_string(),
))
}
};
tokio::fs::write(&path, &bytes)
.await
.map_err(ToolError::IoError)?;
Ok(ToolResult::text(
json!({
"created": true,
"path": format!("outputs/{file_name}"),
"file_name": file_name,
"format": format,
"size_bytes": bytes.len(),
})
.to_string(),
))
}
}
fn safe_file_name(requested: &str, format: &str) -> Result<String, ToolError> {
let base = Path::new(requested)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
let stem = Path::new(base)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("document");
let stem = stem
.chars()
.map(|ch| {
if ch.is_alphanumeric() || matches!(ch, '-' | '_' | ' ') {
ch
} else {
'_'
}
})
.collect::<String>();
let stem = stem.trim().chars().take(96).collect::<String>();
if stem.is_empty()
|| !matches!(
format,
"docx" | "xlsx" | "pptx" | "pdf" | "md" | "txt" | "csv" | "json"
)
{
return Err(ToolError::InvalidInput(
"Invalid document file name or format".to_string(),
));
}
Ok(format!("{stem}.{format}"))
}
async fn create_office(content: &str, format: &str) -> Result<Vec<u8>, ToolError> {
let format = match format {
"docx" => DocumentFormat::Docx,
"xlsx" => DocumentFormat::Xlsx,
"pptx" => DocumentFormat::Pptx,
_ => {
return Err(ToolError::InvalidInput(
"Unsupported Office format".to_string(),
))
}
};
let content = content.to_string();
tokio::task::spawn_blocking(move || {
let mut writer = Cursor::new(Vec::new());
office_oxide::create::create_from_markdown_to_writer(&content, format, &mut writer)
.map_err(|error| ToolError::ExecutionError(error.to_string()))?;
Ok(writer.into_inner())
})
.await
.map_err(|error| ToolError::ExecutionError(error.to_string()))?
}
fn create_pdf(markdown: &str) -> std::io::Result<Vec<u8>> {
let lines = wrap_text(&plain_text(markdown), 72);
let mut document = Document::with_version("1.5");
let cid_font = document.add_object(dictionary! {
"Type" => "Font", "Subtype" => "CIDFontType0", "BaseFont" => "STSong-Light",
"CIDSystemInfo" => dictionary! { "Registry" => "Adobe", "Ordering" => "GB1", "Supplement" => 5 },
});
let font = document.add_object(dictionary! {
"Type" => "Font", "Subtype" => "Type0", "BaseFont" => "STSong-Light",
"Encoding" => "UniGB-UCS2-H", "DescendantFonts" => vec![Object::Reference(cid_font)],
});
let resources = document.add_object(dictionary! { "Font" => dictionary! { "F1" => font } });
let pages = document.new_object_id();
let mut page_ids = Vec::new();
for page_lines in lines.chunks(46) {
let mut operations = vec![
Operation::new("BT", vec![]),
Operation::new("Tf", vec!["F1".into(), 11.into()]),
Operation::new("Td", vec![48.into(), 790.into()]),
Operation::new("TL", vec![16.into()]),
];
for (index, line) in page_lines.iter().enumerate() {
if index > 0 {
operations.push(Operation::new("T*", vec![]));
}
operations.push(Operation::new(
"Tj",
vec![Object::String(
utf16be(line),
StringFormat::Hexadecimal,
)],
));
}
operations.push(Operation::new("ET", vec![]));
let content = Content { operations }.encode().map_err(std::io::Error::other)?;
let content_id = document.add_object(Stream::new(dictionary! {}, content));
let page_id = document.add_object(dictionary! {
"Type" => "Page", "Parent" => pages, "Resources" => resources,
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()], "Contents" => content_id,
});
page_ids.push(page_id);
}
document.objects.insert(
pages,
Object::Dictionary(dictionary! {
"Type" => "Pages", "Kids" => page_ids.iter().copied().map(Object::Reference).collect::<Vec<_>>(),
"Count" => page_ids.len() as i64,
}),
);
let catalog = document.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
document.trailer.set("Root", catalog);
let mut bytes = Vec::new();
document.save_to(&mut bytes).map_err(std::io::Error::other)?;
Ok(bytes)
}
fn plain_text(markdown: &str) -> String {
markdown
.lines()
.map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
.collect::<Vec<_>>()
.join("\n")
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
for source in text.lines() {
if source.is_empty() {
lines.push(String::new());
continue;
}
let chars = source.chars().collect::<Vec<_>>();
for chunk in chars.chunks(width) {
lines.push(chunk.iter().collect());
}
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn utf16be(value: &str) -> Vec<u8> {
value.encode_utf16().flat_map(u16::to_be_bytes).collect()
}
pub fn output_mime(path: &Path) -> Option<&'static str> {
match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
"docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
"xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
"pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
"pdf" => Some("application/pdf"),
"md" => Some("text/markdown"),
"txt" => Some("text/plain"),
"csv" => Some("text/csv"),
"json" => Some("application/json"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pdf_is_valid_and_names_are_sanitized() {
let pdf = create_pdf("# 报告\n\n收入 42").unwrap();
assert!(pdf.starts_with(b"%PDF-"));
Document::load_mem(&pdf).unwrap();
assert_eq!(
safe_file_name("../季度/报告.docx", "docx").unwrap(),
"报告.docx"
);
}
#[tokio::test]
async fn office_outputs_can_be_read_back() {
let markdown = "# Quarterly report\n\n| Item | Value |\n| --- | --- |\n| Revenue | 42 |";
for (extension, format) in [
("docx", DocumentFormat::Docx),
("xlsx", DocumentFormat::Xlsx),
("pptx", DocumentFormat::Pptx),
] {
let bytes = create_office(markdown, extension).await.unwrap();
assert!(bytes.starts_with(b"PK"));
let document = office_oxide::Document::from_reader(Cursor::new(bytes), format).unwrap();
assert!(!document.to_markdown().trim().is_empty());
}
}
}
+1
View File
@@ -16,6 +16,7 @@
pub mod approval; pub mod approval;
pub mod ask; pub mod ask;
pub mod bash_sandbox; pub mod bash_sandbox;
pub mod document;
pub mod permission; pub mod permission;
pub mod run_registry; pub mod run_registry;
pub mod sandbox; pub mod sandbox;
+2 -1
View File
@@ -74,7 +74,7 @@ pub fn classify_tool_risk(tool_name: &str) -> RiskLevel {
if matches!( if matches!(
n.as_str(), n.as_str(),
"write" | "edit" | "create" | "delete" | "rename" | "patch" | "move" | "mkdir" | "remove" "write" | "edit" | "create" | "createdocument" | "delete" | "rename" | "patch" | "move" | "mkdir" | "remove"
) || n.contains("write") ) || n.contains("write")
|| n.contains("edit") || n.contains("edit")
|| n.contains("create") || n.contains("create")
@@ -127,6 +127,7 @@ mod tests {
"Write", "Write",
"Edit", "Edit",
"create_file", "create_file",
"CreateDocument",
"delete_file", "delete_file",
"patch", "patch",
"ApplyPatch", "ApplyPatch",
@@ -72,6 +72,24 @@ pub struct RunClaim {
pub revision: i64, pub revision: i64,
} }
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct AgentRunSummary {
pub id: String,
pub session_id: String,
pub user_id: Option<i64>,
pub status: String,
pub model: String,
pub revision: i64,
pub error_message: Option<String>,
pub cancel_requested_at: Option<String>,
pub started_at: String,
pub finished_at: Option<String>,
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing)]
pub artifacts_json: Option<String>,
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ClaimRunError { pub enum ClaimRunError {
#[error("agent session belongs to another actor")] #[error("agent session belongs to another actor")]
@@ -385,6 +403,59 @@ pub async fn active_run_session(
.map_err(AppError::Database) .map_err(AppError::Database)
} }
pub async fn get_run(
db: &AnyPool,
run_id: &str,
actor_key: &str,
) -> AppResult<Option<AgentRunSummary>> {
crate::db::sql::query_as::<_, AgentRunSummary>(
"SELECT id, session_id, user_id, status, model, revision, error_message,
cancel_requested_at, started_at, finished_at, created_at, updated_at, artifacts_json
FROM agent_runs WHERE id = ? AND actor_key = ?",
)
.bind(run_id)
.bind(actor_key)
.fetch_optional(db)
.await
.map_err(AppError::Database)
}
pub async fn save_run_artifacts(
db: &AnyPool,
run_id: &str,
artifacts_json: &str,
) -> AppResult<()> {
crate::db::sql::query(
"UPDATE agent_runs SET artifacts_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
)
.bind(artifacts_json)
.bind(run_id)
.execute(db)
.await
.map_err(AppError::Database)?;
Ok(())
}
pub async fn latest_session_artifacts(
db: &AnyPool,
session_id: &str,
user_id: Option<i64>,
) -> AppResult<Option<String>> {
crate::db::sql::query_scalar::<_, Option<String>>(
"SELECT artifacts_json FROM agent_runs
WHERE session_id = ? AND ((? IS NULL AND user_id IS NULL) OR user_id = ?)
AND artifacts_json IS NOT NULL
ORDER BY datetime(created_at) DESC, id DESC LIMIT 1",
)
.bind(session_id)
.bind(user_id)
.bind(user_id)
.fetch_optional(db)
.await
.map(|value| value.flatten())
.map_err(AppError::Database)
}
/// Settle a run exactly once and release its session lease only after the /// Settle a run exactly once and release its session lease only after the
/// worker has stopped. The returned boolean is the canonical terminal-event /// worker has stopped. The returned boolean is the canonical terminal-event
/// ownership flag. /// ownership flag.
@@ -812,6 +883,12 @@ mod tests {
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
crate::db::sql::query(include_str!(
"../../../migrations/20260714000001_agent_run_artifacts.sql"
))
.execute(&pool)
.await
.unwrap();
pool pool
} }
@@ -1015,6 +1092,76 @@ mod tests {
)); ));
} }
#[tokio::test]
async fn run_status_is_scoped_to_the_actor() {
let db = pool().await;
let claim = claim_run(
&db,
&Uuid::new_v4().to_string(),
&Uuid::new_v4().to_string(),
&Uuid::new_v4().to_string(),
Some(6),
"user:6",
"m",
"default",
2,
)
.await
.unwrap();
assert!(get_run(&db, &claim.run_id, "user:5")
.await
.unwrap()
.is_none());
let run = get_run(&db, &claim.run_id, "user:6")
.await
.unwrap()
.unwrap();
assert_eq!(run.session_id, claim.session_id);
assert_eq!(run.status, "starting");
}
#[tokio::test]
async fn run_artifacts_round_trip_for_session_owner() {
let db = pool().await;
let session_id = Uuid::new_v4().to_string();
let claim = claim_run(
&db,
&session_id,
&Uuid::new_v4().to_string(),
&Uuid::new_v4().to_string(),
Some(6),
"user:6",
"m",
"default",
2,
)
.await
.unwrap();
let artifacts = r#"[{"publicId":"wa_1","fileName":"report.pdf"}]"#;
save_run_artifacts(&db, &claim.run_id, artifacts)
.await
.unwrap();
let run = get_run(&db, &claim.run_id, "user:6")
.await
.unwrap()
.unwrap();
assert_eq!(run.artifacts_json.as_deref(), Some(artifacts));
assert_eq!(
latest_session_artifacts(&db, &session_id, Some(6))
.await
.unwrap()
.as_deref(),
Some(artifacts)
);
assert!(latest_session_artifacts(&db, &session_id, Some(7))
.await
.unwrap()
.is_none());
}
#[tokio::test] #[tokio::test]
async fn cancelling_run_keeps_lease_until_worker_finishes() { async fn cancelling_run_keeps_lease_until_worker_finishes() {
let db = pool().await; let db = pool().await;
+40
View File
@@ -59,6 +59,14 @@ export type AudioResult = {
url: string; url: string;
}; };
export type AgentArtifact = {
publicId: string;
fileName: string;
fileUrl: string;
mimeType?: string;
sizeBytes?: number;
};
export type ChatMessage = { export type ChatMessage = {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
text: string; text: string;
@@ -66,6 +74,7 @@ export type ChatMessage = {
tools: ToolCall[]; tools: ToolCall[];
creativeJob?: CreativeJob; creativeJob?: CreativeJob;
audioResult?: AudioResult; audioResult?: AudioResult;
artifacts?: AgentArtifact[];
}; };
const TERMINAL_JOB_STATUSES = new Set([ const TERMINAL_JOB_STATUSES = new Set([
@@ -247,12 +256,43 @@ export function AgentMessage({
/> />
)} )}
{message.audioResult && <AudioResultCard result={message.audioResult} audience={audience} />} {message.audioResult && <AudioResultCard result={message.audioResult} audience={audience} />}
{message.artifacts && message.artifacts.length > 0 && (
<div className="agent-artifacts" aria-label="Agent 生成文件">
{message.artifacts.map((artifact) => (
<ArtifactDownload key={artifact.publicId} artifact={artifact} audience={audience} />
))}
</div>
)}
{message.tools.map((tool) => <ToolCard key={`${tool.id}-${tool.name}`} tool={tool} />)} {message.tools.map((tool) => <ToolCard key={`${tool.id}-${tool.name}`} tool={tool} />)}
</div> </div>
</article> </article>
); );
} }
function ArtifactDownload({
artifact,
audience,
}: {
artifact: AgentArtifact;
audience: 'admin' | 'user';
}) {
const href = useAuthedMediaUrl(audience, artifact.fileUrl);
const size = artifact.sizeBytes && artifact.sizeBytes > 0
? `${Math.max(1, Math.round(artifact.sizeBytes / 1024))} KB`
: '文档';
return (
<div className="agent-artifact">
<div>
<strong>{artifact.fileName}</strong>
<span>{size}</span>
</div>
{href
? <a href={href} download={artifact.fileName}>下载</a>
: <span>准备下载...</span>}
</div>
);
}
function AudioResultCard({ result, audience }: { result: AudioResult; audience: 'admin' | 'user' }) { function AudioResultCard({ result, audience }: { result: AudioResult; audience: 'admin' | 'user' }) {
const src = useAuthedMediaUrl(audience, result.url); const src = useAuthedMediaUrl(audience, result.url);
return ( return (
+48
View File
@@ -2839,6 +2839,54 @@
min-width: 0; min-width: 0;
} }
.agent-artifacts {
display: grid;
gap: 8px;
margin-top: 12px;
}
.agent-artifact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 52px;
padding: 10px 12px;
border: 1px solid var(--color-border, #dce7e2);
border-radius: 8px;
background: var(--color-bg-card, #fff);
}
.agent-artifact > div {
display: grid;
min-width: 0;
gap: 2px;
}
.agent-artifact strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-artifact span {
color: var(--color-text-muted, #718078);
font-size: 12px;
}
.agent-artifact a {
flex: 0 0 auto;
color: var(--color-primary, #167c68);
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.agent-artifact a:hover {
text-decoration: underline;
}
.agent-workbench-composer { .agent-workbench-composer {
position: relative; position: relative;
z-index: 10; z-index: 10;
+37 -1
View File
@@ -5,7 +5,7 @@ import {
parseAgentSseFrame, parseAgentSseFrame,
readAgentSse, readAgentSse,
} from '../agentStream.js'; } from '../agentStream.js';
import { normalizeJimengErrorMessage } from './AgentWorkbench.js'; import { normalizeAgentArtifacts, normalizeJimengErrorMessage } from './AgentWorkbench.js';
const agentWorkbenchSource = readFileSync(new URL('./AgentWorkbench.tsx', import.meta.url), 'utf8'); const agentWorkbenchSource = readFileSync(new URL('./AgentWorkbench.tsx', import.meta.url), 'utf8');
@@ -83,4 +83,40 @@ describe('AgentWorkbench session restoration', () => {
expect(normalizePermissionMode('full-access')).toBe('full_access'); expect(normalizePermissionMode('full-access')).toBe('full_access');
expect(normalizePermissionMode('unexpected')).toBe('default'); expect(normalizePermissionMode('unexpected')).toBe('default');
}); });
it('normalizes downloadable artifacts and rejects incomplete rows', () => {
expect(normalizeAgentArtifacts([
{
public_id: 'wa_1',
file_name: 'report.pdf',
file_url: '/api/user/workspace/assets/wa_1/file',
size_bytes: 1024,
},
{ publicId: 'incomplete' },
])).toEqual([{
publicId: 'wa_1',
fileName: 'report.pdf',
fileUrl: '/api/user/workspace/assets/wa_1/file',
mimeType: undefined,
sizeBytes: 1024,
}]);
expect(agentWorkbenchSource).toContain('normalizeAgentArtifacts(payload?.artifacts)');
expect(agentWorkbenchSource).toContain('normalizeAgentArtifacts(d.artifacts)');
});
});
describe('AgentWorkbench detached run recovery', () => {
it('recovers run and interaction state after the SSE transport disconnects', () => {
expect(agentWorkbenchSource).toContain('/api/agent/runs/${encodeURIComponent(runId)}');
expect(agentWorkbenchSource).toContain('ACTIVE_AGENT_RUN_STATUSES.has(recovered.status)');
expect(agentWorkbenchSource).toContain("pending?.kind === 'approval'");
expect(agentWorkbenchSource).toContain("pending?.kind === 'input'");
expect(agentWorkbenchSource).toContain('normalizeAgentArtifacts(payload?.artifacts)');
expect(agentWorkbenchSource).toContain('window.setTimeout(poll, 2000)');
});
it('allows cancelling an active run after its original request controller is gone', () => {
expect(agentWorkbenchSource).toContain('if (cancellingRun || (!controller && !runId)) return;');
expect(agentWorkbenchSource).toContain('{(running || activeRunId) && (');
});
}); });
+162 -10
View File
@@ -19,6 +19,7 @@ import {
import { import {
AgentMessage, AgentMessage,
isTerminalJobStatus, isTerminalJobStatus,
type AgentArtifact,
type ChatMessage, type ChatMessage,
type CreativeJob, type CreativeJob,
type CreativeJobRequestSnapshot, type CreativeJobRequestSnapshot,
@@ -100,6 +101,27 @@ type AgentSession = {
updatedAt?: string; updatedAt?: string;
}; };
type AgentRunStatus = {
id: string;
session_id: string;
status: string;
error_message?: string | null;
};
type PendingAgentInteraction = {
interaction_id: string;
kind: 'approval' | 'input';
payload?: Record<string, unknown>;
};
const ACTIVE_AGENT_RUN_STATUSES = new Set([
'starting',
'running',
'waiting_approval',
'waiting_input',
'cancelling',
]);
type CreationModeOption = { type CreationModeOption = {
value: CreationMode; value: CreationMode;
label: string; label: string;
@@ -614,6 +636,25 @@ function normalizeCreativeJob(raw: unknown, kind: 'image' | 'video'): CreativeJo
}; };
} }
export function normalizeAgentArtifacts(raw: unknown): AgentArtifact[] {
if (!Array.isArray(raw)) return [];
return raw.flatMap((item) => {
if (!item || typeof item !== 'object') return [];
const row = item as Record<string, unknown>;
const publicId = String(row.publicId ?? row.public_id ?? '').trim();
const fileName = String(row.fileName ?? row.file_name ?? '').trim();
const fileUrl = String(row.fileUrl ?? row.file_url ?? '').trim();
if (!publicId || !fileName || !fileUrl) return [];
return [{
publicId,
fileName,
fileUrl,
mimeType: typeof (row.mimeType ?? row.mime_type) === 'string' ? String(row.mimeType ?? row.mime_type) : undefined,
sizeBytes: Number.isFinite(Number(row.sizeBytes ?? row.size_bytes)) ? Number(row.sizeBytes ?? row.size_bytes) : undefined,
}];
});
}
function seedanceCreativeJob(job: SeedanceJob): CreativeJob { function seedanceCreativeJob(job: SeedanceJob): CreativeJob {
return { return {
id: job.id, id: job.id,
@@ -1660,22 +1701,30 @@ export default function AgentWorkbench() {
case 'result': case 'result':
flushAgentDeltas(); flushAgentDeltas();
setCancellingRun(false); setCancellingRun(false);
activeRunIdRef.current = null;
setActiveRunId(null);
{ {
const eventUsage = d.usage && typeof d.usage === 'object' const eventUsage = d.usage && typeof d.usage === 'object'
? d.usage as Record<string, unknown> ? d.usage as Record<string, unknown>
: d; : d;
setUsage({ setUsage({
inputTokens: normalizeNumber(eventUsage.input_tokens, eventUsage.inputTokens), inputTokens: normalizeNumber(eventUsage.input_tokens, eventUsage.inputTokens),
outputTokens: normalizeNumber(eventUsage.output_tokens, eventUsage.outputTokens), outputTokens: normalizeNumber(eventUsage.output_tokens, eventUsage.outputTokens),
totalTokens: normalizeNumber(eventUsage.total_tokens, eventUsage.totalTokens), totalTokens: normalizeNumber(eventUsage.total_tokens, eventUsage.totalTokens),
costUsd: normalizeNumber(eventUsage.cost_usd, eventUsage.costUsd), costUsd: normalizeNumber(eventUsage.cost_usd, eventUsage.costUsd),
numTurns: normalizeNumber(eventUsage.num_turns, eventUsage.numTurns), numTurns: normalizeNumber(eventUsage.num_turns, eventUsage.numTurns),
}); });
const artifacts = normalizeAgentArtifacts(d.artifacts);
if (artifacts.length > 0) {
updateAssistant((m) => { m.artifacts = artifacts; });
}
} }
break; break;
case 'error': case 'error':
flushAgentDeltas(); flushAgentDeltas();
setCancellingRun(false); setCancellingRun(false);
activeRunIdRef.current = null;
setActiveRunId(null);
toast.error(String(d.message ?? d.error ?? '执行出错')); toast.error(String(d.message ?? d.error ?? '执行出错'));
updateAssistant((m) => { updateAssistant((m) => {
m.text += `\n\n[错误: ${String(d.message ?? d.error ?? '未知错误')}]`; m.text += `\n\n[错误: ${String(d.message ?? d.error ?? '未知错误')}]`;
@@ -1684,6 +1733,8 @@ export default function AgentWorkbench() {
case 'cancelled': case 'cancelled':
flushAgentDeltas(); flushAgentDeltas();
setCancellingRun(false); setCancellingRun(false);
activeRunIdRef.current = null;
setActiveRunId(null);
updateAssistant((m) => { updateAssistant((m) => {
if (!m.text.includes('[已取消]')) m.text += '\n\n[已取消]'; if (!m.text.includes('[已取消]')) m.text += '\n\n[已取消]';
}); });
@@ -1691,6 +1742,8 @@ export default function AgentWorkbench() {
case 'interrupted': case 'interrupted':
flushAgentDeltas(); flushAgentDeltas();
setCancellingRun(false); setCancellingRun(false);
activeRunIdRef.current = null;
setActiveRunId(null);
updateAssistant((m) => { updateAssistant((m) => {
if (!m.text.includes('[运行已中断]')) m.text += '\n\n[运行已中断]'; if (!m.text.includes('[运行已中断]')) m.text += '\n\n[运行已中断]';
}); });
@@ -1708,6 +1761,54 @@ export default function AgentWorkbench() {
[flushAgentDeltas, queueAgentDelta, toast, updateAssistant], [flushAgentDeltas, queueAgentDelta, toast, updateAssistant],
); );
const recoverRunState = useCallback(async (runId: string): Promise<AgentRunStatus> => {
const payload = await fetchJson(`/api/agent/runs/${encodeURIComponent(runId)}`);
const run = payload?.run as AgentRunStatus | undefined;
if (!run?.id || !run.session_id || !run.status) {
throw new Error('Agent 运行状态响应无效');
}
const active = ACTIVE_AGENT_RUN_STATUSES.has(run.status);
activeRunIdRef.current = active ? run.id : null;
setActiveRunId(active ? run.id : null);
setSessionId(run.session_id);
setCancellingRun(run.status === 'cancelling');
const artifacts = normalizeAgentArtifacts(payload?.artifacts);
if (artifacts.length > 0) {
updateAssistant((message) => { message.artifacts = artifacts; });
}
const interactions = Array.isArray(payload?.interactions)
? payload.interactions as PendingAgentInteraction[]
: [];
const pending = interactions[0];
if (pending?.kind === 'approval') {
const data = pending.payload ?? {};
setApproval({
requestId: pending.interaction_id,
runId: run.id,
name: String(data.name ?? ''),
input: data.input,
risk: String(data.risk ?? ''),
});
setAsk(null);
} else if (pending?.kind === 'input') {
const data = pending.payload ?? {};
setAsk({
requestId: pending.interaction_id,
runId: run.id,
question: String(data.question ?? ''),
options: Array.isArray(data.options) ? data.options.map(String) : [],
});
setApproval(null);
} else if (!active) {
setApproval(null);
setAsk(null);
}
return run;
}, [fetchJson, updateAssistant]);
const send = useCallback(async () => { const send = useCallback(async () => {
const rawPrompt = input.trim(); const rawPrompt = input.trim();
const attachmentText = attachments.length > 0 const attachmentText = attachments.length > 0
@@ -1870,10 +1971,26 @@ export default function AgentWorkbench() {
m.text += '\n\n[已停止]'; m.text += '\n\n[已停止]';
}); });
} else { } else {
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`); const runId = activeRunIdRef.current;
updateAssistant((m) => { let recovered: AgentRunStatus | null = null;
m.text += '\n\n[连接中断]'; if (runId) {
}); try {
recovered = await recoverRunState(runId);
} catch {
// Keep the original transport error as the user-facing failure.
}
}
if (recovered && ACTIVE_AGENT_RUN_STATUSES.has(recovered.status)) {
toast.info('连接已中断,Agent 仍在后台运行,正在自动恢复状态');
updateAssistant((m) => {
m.text += '\n\n[连接中断,任务仍在后台运行]';
});
} else {
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
updateAssistant((m) => {
m.text += '\n\n[连接中断]';
});
}
} }
} finally { } finally {
flushAgentDeltas(); flushAgentDeltas();
@@ -1882,14 +1999,14 @@ export default function AgentWorkbench() {
setCancellingRun(false); setCancellingRun(false);
void loadSessions(); void loadSessions();
} }
}, [activeCanvasAction, agentModel, attachments, authToken, clearAttachments, createJimengJob, createSeedanceJob, createVoiceover, creationMode, customAgentModel, customJimengModel, customSeedanceModel, currentCreationMode.label, flushAgentDeltas, handleEvent, input, jimengImageModel, jimengReferenceCount, jimengVideoModel, loadSessions, mode, parsedJimengEndFrameUrls.length, parsedJimengImageUrls.length, running, seedanceEnabled, seedanceModel, seedanceStatusError, selectedAgentModel, sessionId, toast, updateAssistant, videoProvider]); }, [activeCanvasAction, agentModel, attachments, authToken, clearAttachments, createJimengJob, createSeedanceJob, createVoiceover, creationMode, customAgentModel, customJimengModel, customSeedanceModel, currentCreationMode.label, flushAgentDeltas, handleEvent, input, jimengImageModel, jimengReferenceCount, jimengVideoModel, loadSessions, mode, parsedJimengEndFrameUrls.length, parsedJimengImageUrls.length, recoverRunState, running, seedanceEnabled, seedanceModel, seedanceStatusError, selectedAgentModel, sessionId, toast, updateAssistant, videoProvider]);
const stop = useCallback(() => { const stop = useCallback(() => {
const controller = abortRef.current; const controller = abortRef.current;
const runId = activeRunIdRef.current; const runId = activeRunIdRef.current;
if (!controller || cancellingRun) return; if (cancellingRun || (!controller && !runId)) return;
if (!runId) { if (!runId) {
controller.abort(); controller?.abort();
return; return;
} }
setCancellingRun(true); setCancellingRun(true);
@@ -1972,6 +2089,11 @@ export default function AgentWorkbench() {
}); });
if (controller.signal.aborted || restoreAbortRef.current !== controller) return; if (controller.signal.aborted || restoreAbortRef.current !== controller) return;
const restored = transcriptToMessages(payload?.transcript); const restored = transcriptToMessages(payload?.transcript);
const artifacts = normalizeAgentArtifacts(payload?.artifacts);
if (artifacts.length > 0) {
const assistant = [...restored].reverse().find((message) => message.role === 'assistant');
if (assistant) assistant.artifacts = artifacts;
}
setSessionId(id); setSessionId(id);
activeRunIdRef.current = null; activeRunIdRef.current = null;
setActiveRunId(null); setActiveRunId(null);
@@ -2008,6 +2130,36 @@ export default function AgentWorkbench() {
[agentModels, fetchJson, leaveCreativeView, running, toast], [agentModels, fetchJson, leaveCreativeView, running, toast],
); );
useEffect(() => {
if (running || !activeRunId) return undefined;
let disposed = false;
let timer: number | undefined;
const poll = async () => {
try {
const run = await recoverRunState(activeRunId);
if (disposed) return;
if (ACTIVE_AGENT_RUN_STATUSES.has(run.status)) {
timer = window.setTimeout(poll, 2000);
return;
}
await restoreSession(run.session_id);
if (!disposed) {
void loadSessions();
toast.info(run.status === 'succeeded' ? '后台 Agent 运行已完成' : `后台 Agent 运行已${run.status}`);
}
} catch {
if (!disposed) timer = window.setTimeout(poll, 4000);
}
};
void poll();
return () => {
disposed = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [activeRunId, loadSessions, recoverRunState, restoreSession, running, toast]);
const isFreshSession = messages.length === 0; const isFreshSession = messages.length === 0;
const activeModelLabel = useMemo(() => { const activeModelLabel = useMemo(() => {
if (creationMode === 'agent') { if (creationMode === 'agent') {
@@ -2328,7 +2480,7 @@ export default function AgentWorkbench() {
<em>模型与参数</em> <em>模型与参数</em>
</span> </span>
</div> </div>
{running && ( {(running || activeRunId) && (
<button className="agent-workbench-stop-top" type="button" onClick={stop} disabled={cancellingRun}> <button className="agent-workbench-stop-top" type="button" onClick={stop} disabled={cancellingRun}>
{cancellingRun ? '取消中...' : '停止'} {cancellingRun ? '取消中...' : '停止'}
</button> </button>
-1
View File
@@ -1 +0,0 @@
{"v":1}
-6
View File
@@ -1,6 +0,0 @@
{
"git": {
"sha1": "bab1b022bd56a64f9a08b46b36b97c5cff19d77e"
},
"path_in_vcs": "sqlx-core"
}
-92
View File
@@ -1,92 +0,0 @@
[package]
name = "sqlx-core"
description = "Core of SQLx, the rust SQL toolkit. Not intended to be used directly."
version.workspace = true
license.workspace = true
edition.workspace = true
authors.workspace = true
repository.workspace = true
[package.metadata.docs.rs]
features = ["offline"]
[features]
default = []
migrate = ["sha2", "crc"]
any = []
json = ["serde", "serde_json"]
# for conditional compilation
_rt-async-std = ["async-std", "async-io"]
_rt-tokio = ["tokio", "tokio-stream"]
_tls-native-tls = ["native-tls"]
_tls-rustls-aws-lc-rs = ["_tls-rustls", "rustls/aws-lc-rs", "webpki-roots"]
_tls-rustls-ring-webpki = ["_tls-rustls", "rustls/ring", "webpki-roots"]
_tls-rustls-ring-native-roots = ["_tls-rustls", "rustls/ring", "rustls-native-certs"]
_tls-rustls = ["rustls"]
_tls-none = []
# support offline/decoupled building (enables serialization of `Describe`)
offline = ["serde", "either/serde"]
[dependencies]
# Runtimes
async-std = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }
# TLS
native-tls = { version = "0.2.10", optional = true }
rustls = { version = "0.23.15", default-features = false, features = ["std", "tls12"], optional = true }
webpki-roots = { version = "0.26", optional = true }
rustls-native-certs = { version = "0.8.0", optional = true }
# Type Integrations
bit-vec = { workspace = true, optional = true }
bigdecimal = { workspace = true, optional = true }
rust_decimal = { workspace = true, optional = true }
time = { workspace = true, optional = true }
ipnet = { workspace = true, optional = true }
ipnetwork = { workspace = true, optional = true }
mac_address = { workspace = true, optional = true }
uuid = { workspace = true, optional = true }
async-io = { version = "1.9.0", optional = true }
base64 = { version = "0.22.0", default-features = false, features = ["std"] }
bytes = "1.1.0"
chrono = { version = "0.4.34", default-features = false, features = ["clock"], optional = true }
crc = { version = "3", optional = true }
crossbeam-queue = "0.3.2"
either = "1.6.1"
futures-core = { version = "0.3.19", default-features = false }
futures-io = "0.3.24"
futures-intrusive = "0.5.0"
futures-util = { version = "0.3.19", default-features = false, features = ["alloc", "sink", "io"] }
log = { version = "0.4.18", default-features = false }
memchr = { version = "2.4.1", default-features = false }
once_cell = "1.9.0"
percent-encoding = "2.1.0"
regex = { version = "1.5.5", optional = true }
serde = { version = "1.0.132", features = ["derive", "rc"], optional = true }
serde_json = { version = "1.0.73", features = ["raw_value"], optional = true }
sha2 = { version = "0.10.0", default-features = false, optional = true }
#sqlformat = "0.2.0"
thiserror = "2.0.0"
tokio-stream = { version = "0.1.8", features = ["fs"], optional = true }
tracing = { version = "0.1.37", features = ["log"] }
smallvec = "1.7.0"
url = { version = "2.2.2" }
bstr = { version = "1.0", default-features = false, features = ["std"], optional = true }
hashlink = "0.10.0"
indexmap = "2.0"
event-listener = "5.2.0"
hashbrown = "0.15.0"
[dev-dependencies]
sqlx = { workspace = true, features = ["postgres", "sqlite", "mysql", "migrate", "macros", "time", "uuid"] }
tokio = { version = "1", features = ["rt"] }
[lints]
workspace = true
-1
View File
@@ -1 +0,0 @@
{"v":1}
-6
View File
@@ -1,6 +0,0 @@
{
"git": {
"sha1": "bab1b022bd56a64f9a08b46b36b97c5cff19d77e"
},
"path_in_vcs": "sqlx-mysql"
}
-79
View File
@@ -1,79 +0,0 @@
[package]
name = "sqlx-mysql"
documentation = "https://docs.rs/sqlx"
description = "MySQL driver implementation for SQLx. Not for direct use; see the `sqlx` crate for details."
version.workspace = true
license.workspace = true
edition.workspace = true
authors.workspace = true
repository.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
json = ["sqlx-core/json", "serde"]
any = ["sqlx-core/any"]
offline = ["sqlx-core/offline", "serde/derive"]
migrate = ["sqlx-core/migrate"]
# Type Integration features
bigdecimal = ["dep:bigdecimal", "sqlx-core/bigdecimal"]
chrono = ["dep:chrono", "sqlx-core/chrono"]
rust_decimal = ["dep:rust_decimal", "rust_decimal/maths", "sqlx-core/rust_decimal"]
time = ["dep:time", "sqlx-core/time"]
uuid = ["dep:uuid", "sqlx-core/uuid"]
[dependencies]
sqlx-core = { workspace = true }
# Futures crates
futures-channel = { version = "0.3.19", default-features = false, features = ["sink", "alloc", "std"] }
futures-core = { version = "0.3.19", default-features = false }
futures-io = "0.3.24"
futures-util = { version = "0.3.19", default-features = false, features = ["alloc", "sink", "io"] }
# Cryptographic Primitives
crc = "3.0.0"
digest = { version = "0.10.0", default-features = false, features = ["std"] }
hkdf = "0.12.0"
hmac = { version = "0.12.0", default-features = false }
md-5 = { version = "0.10.0", default-features = false }
rand = { version = "0.8.4", default-features = false, features = ["std", "std_rng"] }
rsa = "0.9"
sha1 = { version = "0.10.1", default-features = false }
sha2 = { version = "0.10.0", default-features = false }
# Type Integrations (versions inherited from `[workspace.dependencies]`)
bigdecimal = { workspace = true, optional = true }
chrono = { workspace = true, optional = true }
rust_decimal = { workspace = true, optional = true }
time = { workspace = true, optional = true }
uuid = { workspace = true, optional = true }
# Misc
atoi = "2.0"
base64 = { version = "0.22.0", default-features = false, features = ["std"] }
bitflags = { version = "2", default-features = false, features = ["serde"] }
byteorder = { version = "1.4.3", default-features = false, features = ["std"] }
bytes = "1.1.0"
dotenvy = "0.15.5"
either = "1.6.1"
generic-array = { version = "0.14.4", default-features = false }
hex = "0.4.3"
itoa = "1.0.1"
log = "0.4.18"
memchr = { version = "2.4.1", default-features = false }
once_cell = "1.9.0"
percent-encoding = "2.1.0"
smallvec = "1.7.0"
stringprep = "0.1.2"
thiserror = "2.0.0"
tracing = { version = "0.1.37", features = ["log"] }
whoami = { version = "1.2.1", default-features = false }
serde = { version = "1.0.144", optional = true }
[dev-dependencies]
sqlx = { workspace = true, features = ["mysql"] }
[lints]
workspace = true
-1
View File
@@ -1 +0,0 @@
{"v":1}
-6
View File
@@ -1,6 +0,0 @@
{
"git": {
"sha1": "bab1b022bd56a64f9a08b46b36b97c5cff19d77e"
},
"path_in_vcs": "sqlx-sqlite"
}
-77
View File
@@ -1,77 +0,0 @@
[package]
name = "sqlx-sqlite"
documentation = "https://docs.rs/sqlx"
description = "SQLite driver implementation for SQLx. Not for direct use; see the `sqlx` crate for details."
version.workspace = true
license.workspace = true
edition.workspace = true
authors.workspace = true
repository.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
any = ["sqlx-core/any"]
json = ["sqlx-core/json", "serde"]
offline = ["sqlx-core/offline", "serde"]
migrate = ["sqlx-core/migrate"]
# Type integrations
chrono = ["dep:chrono", "sqlx-core/chrono"]
time = ["dep:time", "sqlx-core/time"]
uuid = ["dep:uuid", "sqlx-core/uuid"]
regexp = ["dep:regex"]
preupdate-hook = ["libsqlite3-sys/preupdate_hook"]
bundled = ["libsqlite3-sys/bundled"]
unbundled = ["libsqlite3-sys/buildtime_bindgen"]
[dependencies]
futures-core = { version = "0.3.19", default-features = false }
futures-channel = { version = "0.3.19", default-features = false, features = ["sink", "alloc", "std"] }
# used by the SQLite worker thread to block on the async mutex that locks the database handle
futures-executor = { version = "0.3.19" }
futures-intrusive = "0.5.0"
futures-util = { version = "0.3.19", default-features = false, features = ["alloc", "sink"] }
chrono = { workspace = true, optional = true }
time = { workspace = true, optional = true }
uuid = { workspace = true, optional = true }
url = { version = "2.2.2" }
percent-encoding = "2.1.0"
serde_urlencoded = "0.7"
flume = { version = "0.11.0", default-features = false, features = ["async"] }
atoi = "2.0"
log = "0.4.18"
tracing = { version = "0.1.37", features = ["log"] }
thiserror = "2.0.0"
serde = { version = "1.0.145", features = ["derive"], optional = true }
regex = { version = "1.5.5", optional = true }
[dependencies.libsqlite3-sys]
version = "0.30.1"
default-features = false
features = [
"pkg-config",
"vcpkg",
"unlock_notify"
]
[dependencies.sqlx-core]
workspace = true
[dev-dependencies]
sqlx = { workspace = true, default-features = false, features = ["macros", "runtime-tokio", "tls-none", "sqlite"] }
[lints]
workspace = true
[package.metadata.docs.rs]
features = ["bundled", "any", "json", "chrono", "time", "uuid"]