Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dde5679e1 | |||
| 9fe770ad26 | |||
| cf73250999 | |||
| 379dfd7b11 | |||
| bdd26a5e43 | |||
| d68da02d9b | |||
| 7554730f56 | |||
| 5f6d5ce5a0 | |||
| 86d310ab35 |
+1
-1
@@ -34,6 +34,7 @@ docker/*.tar
|
||||
.tours/
|
||||
.trellis/
|
||||
.codegraph/
|
||||
.hallmark/
|
||||
bun.lock
|
||||
|
||||
# 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
|
||||
package-lock.json
|
||||
package-lock.json
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -1,103 +1,81 @@
|
||||
# BoosAPI Engineering Rules
|
||||
|
||||
> **AI开发**: 使用 **gpt-5.6**(`api.boos.lat/v1`)进行开发工作。
|
||||
> 本项目为纯 Rust 项目,无 Node.js 运行时依赖。
|
||||
## Project
|
||||
|
||||
## 项目结构
|
||||
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/
|
||||
│ ├── main.rs ← 入口
|
||||
│ ├── lib.rs
|
||||
│ ├── config.rs
|
||||
│ ├── error.rs
|
||||
│ ├── routes/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── comfyui_agent.rs ← ComfyUI Agent SSE 聊天
|
||||
│ │ ├── comfyui/ ← ComfyUI 代理路由
|
||||
│ │ ├── proxy/ ← OpenAI 兼容代理
|
||||
│ │ ├── auth/ ← 认证
|
||||
│ │ └── ...
|
||||
│ ├── proxy/ ← 代理核心(Conductor/Token Router)
|
||||
│ ├── services/ ← 后台服务
|
||||
│ ├── db/ ← 数据库
|
||||
│ └── transformers/ ← 协议转换
|
||||
├── dist/web/ ← 前端构建产物(由 Rust 静态 serve)
|
||||
├── migrations/ ← SQLite 迁移
|
||||
├── config.toml ← 运行配置
|
||||
├── Dockerfile
|
||||
└── Cargo.toml
|
||||
|
||||
The frontend uses Vite during development and is built into `dist/web/` for
|
||||
the Rust server. Do not commit generated build output, local databases, logs,
|
||||
or dependency installation directories.
|
||||
|
||||
## Data And Migrations
|
||||
|
||||
- Keep SQLite, PostgreSQL, and MySQL migration history aligned when a schema
|
||||
change affects more than one database.
|
||||
- Use the existing database helpers and query style.
|
||||
- Do not commit local database files or credentials.
|
||||
- `vendor/sqlx-*` is currently intentional: `Cargo.toml` patches SQLx core,
|
||||
MySQL, and SQLite crates to these local sources. Do not remove them without
|
||||
first removing the patch and validating the complete Cargo build.
|
||||
|
||||
## Picture Agent
|
||||
|
||||
The Picture Agent routes are under `src/routes/picture_agent.rs`. Template data
|
||||
is fetched from the configured remote gallery source at request time; do not
|
||||
add the Linux Do template JSON as a bundled local asset.
|
||||
|
||||
Picture generation jobs must continue to use the existing authentication,
|
||||
background task, workspace asset, and usage tracking services.
|
||||
|
||||
## 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`.
|
||||
|
||||
行为契约:
|
||||
- **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 双向同步
|
||||
## Frontend Sessions
|
||||
|
||||
支持的结构化标记(输出由 LLM 生成、由 agent SSE 解析下发):
|
||||
- 【CHARACTER】 → 自动调用 gpt-image-2 生成四视图
|
||||
- 【SCENE】 → 自动生成场景图
|
||||
- 【TTS】 → 自动调用 TTS API
|
||||
- 【WORKFLOW】 → 输出 ComfyUI 工作流 JSON
|
||||
Use `src/web/authSession.ts` for new session work:
|
||||
|
||||
## 即梦 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` 单一事实源):
|
||||
- 图片 `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` 白名单。
|
||||
## Editing And Verification
|
||||
|
||||
### 即梦在「站点管理 / 连接管理」里的特殊处理(platform=`jimeng`)
|
||||
|
||||
即梦作为一个 `platform = "jimeng"` 录进现有 `sites` + `accounts` 体系(复用 CRUD / 列表 / 状态 UI),但因为它**不是 OpenAI 兼容协议**(火山 V4 HMAC 签名、异步两步生成、无 `/models` 端点),所有 OpenAI 专属路径都为它开了**干净的分支**,绝不污染其它平台:
|
||||
|
||||
- **平台判定**:`routes::admin::accounts::is_jimeng_platform(&site.platform)`(大小写不敏感)是唯一事实源;站点 `platform` 字段为自由字符串,无白名单,前端下拉新增 `jimeng` 选项即可。
|
||||
- **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` 拒绝(留空即保留原值)。
|
||||
- **验证(签名探测)**:`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`**,绝不触发生成。
|
||||
- **不进路由表**:`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)`;新增登录错误请加在该处而不是各自分支里手写。
|
||||
- Keep changes scoped and preserve unrelated user changes.
|
||||
- Prefer `rg` for searches and structured parsers for JSON/TOML/SQL.
|
||||
- Use `apply_patch` for manual edits.
|
||||
- Add focused tests for changed behavior.
|
||||
- Run the narrowest relevant test or Cargo check before committing.
|
||||
- Never commit secrets, generated artifacts, local environment files, or
|
||||
temporary deployment output.
|
||||
|
||||
@@ -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
@@ -527,6 +527,7 @@ dependencies = [
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
"libc",
|
||||
"lopdf",
|
||||
"md-5",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
|
||||
@@ -96,6 +96,7 @@ include_dir = "0.7"
|
||||
mime_guess = "2"
|
||||
office_oxide = "=0.1.5"
|
||||
pdf-extract = "=0.12.0"
|
||||
lopdf = { version = "0.42", default-features = false }
|
||||
|
||||
# Embedded AI agent engine (MIT, vendored as a path dependency). Provides the
|
||||
# agentic loop, built-in tools, permission callback and cost tracking that the
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
## 2. 开发环境 (Development)
|
||||
|
||||
### 前提条件
|
||||
- **Rust**: 1.85+ (推荐使用 rustup)
|
||||
- **Node.js**: 20+ (仅用于前端构建)
|
||||
- **Rust**: 1.88+ (推荐使用 rustup)
|
||||
- **Bun**: 1.2+ (仅用于前端构建,生产运行不需要 Bun)
|
||||
- **SQLite**: 系统自带或手动安装
|
||||
|
||||
### 步骤
|
||||
@@ -39,24 +39,26 @@
|
||||
```
|
||||
|
||||
2. **配置环境**:
|
||||
复制 `config.toml.example` 并根据需要修改:
|
||||
编辑仓库中的 `config.toml`,或直接设置环境变量:
|
||||
```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. **运行前端 (热更新模式)**:
|
||||
```bash
|
||||
npm install
|
||||
# 启动 vite 开发服务器
|
||||
npm run dev:web
|
||||
bun install --frozen-lockfile
|
||||
# 启动 Vite 开发服务器
|
||||
bun run dev
|
||||
```
|
||||
默认访问 `http://localhost:5173`。Vite 会将 `/api` 请求代理到后端 `localhost:4000`。
|
||||
|
||||
4. **运行后端**:
|
||||
```bash
|
||||
# 使用默认配置运行
|
||||
cargo run
|
||||
cargo run --release
|
||||
# 推荐:使用 cargo-watch 自动重载
|
||||
# cargo install cargo-watch
|
||||
# cargo watch -x run
|
||||
@@ -120,10 +122,7 @@ config.toml
|
||||
|
||||
1. **构建前端与后端**:
|
||||
```bash
|
||||
# 构建前端
|
||||
npm run build
|
||||
# 构建后端
|
||||
cargo build --release
|
||||
./build-and-run.sh --production
|
||||
```
|
||||
|
||||
2. **安装**:
|
||||
@@ -232,10 +231,21 @@ backend = "boosapi"
|
||||
|
||||
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 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)
|
||||
|
||||
### 检查配置
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</p>
|
||||
|
||||
<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="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">
|
||||
@@ -57,7 +57,7 @@
|
||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
||||
|
||||
## 2026-06 更新重点
|
||||
## 2026-07 更新重点
|
||||
|
||||
- **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。
|
||||
- **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。
|
||||
@@ -66,8 +66,11 @@
|
||||
- **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。
|
||||
- **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。
|
||||
- **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 解析后再转成本地显示。
|
||||
- **图片助手模板**:模板接口按请求读取远程图库数据,不在仓库中捆绑大体积 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`),开箱即用,无需额外配置
|
||||
- **回退远端**:设置 `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** 下游格式
|
||||
@@ -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。
|
||||
|
||||
> 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 # 默认配置文件
|
||||
├── Dockerfile # 多阶段构建
|
||||
├── docker/
|
||||
│ └── docker-compose.yml
|
||||
│ ├── docker-compose.databases.yml
|
||||
│ └── docker-compose.override.yml
|
||||
├── src/
|
||||
│ ├── main.rs # 入口
|
||||
│ ├── lib.rs # 库入口
|
||||
@@ -359,6 +370,8 @@ docker compose up -d
|
||||
│ ├── billing/ # 计费逻辑
|
||||
│ └── notification/ # 通知推送
|
||||
├── migrations/ # SQLite 迁移
|
||||
├── migrations_pg/ # PostgreSQL 迁移
|
||||
├── migrations_mysql/ # MySQL/MariaDB 迁移
|
||||
├── src/web/ # 管理台前端源码(React + Vite)
|
||||
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
||||
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
||||
@@ -489,7 +502,7 @@ socket 不需要的 PAM/LDAP,并剥离符号;可选体积模式额外使用
|
||||
| 样式 | Tailwind CSS v4 |
|
||||
| 数据可视化 | VChart |
|
||||
| 容器化 | Docker + Docker Compose + K3s |
|
||||
| 构建工具 | Cargo(后端)/ npm(前端静态构建) |
|
||||
| 构建工具 | Cargo(后端)/ Bun(前端静态构建) |
|
||||
|
||||
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
|
||||
|
||||
@@ -541,21 +554,21 @@ export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
|
||||
|
||||
```bash
|
||||
# 安装依赖(首次)
|
||||
npm install
|
||||
bun install --frozen-lockfile
|
||||
|
||||
# 开发模式(热更新,需要 Node.js)
|
||||
cd src/web && npx vite --host 0.0.0.0
|
||||
# 开发模式(热更新,需要 Bun)
|
||||
bun run dev --host 0.0.0.0
|
||||
|
||||
# 类型检查与构建生产版本
|
||||
npm run typecheck:web
|
||||
npm run build:web
|
||||
bun run typecheck:web
|
||||
bun run build:web
|
||||
# 输出到 dist/web/,Rust 后端自动 serve
|
||||
```
|
||||
|
||||
生图助手的 tldraw 无限画布在 HTTPS 生产域名需要有效许可证。许可证是域名绑定的客户端构建变量,构建时注入即可:
|
||||
|
||||
```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`。
|
||||
@@ -564,26 +577,23 @@ Workflow UI 前端资产单独更新:
|
||||
|
||||
```bash
|
||||
# Comfy Cloud 前端
|
||||
npm run workflow-ui:install:cloud
|
||||
bun run workflow-ui:install:cloud
|
||||
|
||||
# External / RunningHub 前端
|
||||
npm run workflow-ui:install:external
|
||||
bun run workflow-ui:install:external
|
||||
```
|
||||
|
||||
可选使用 Bun 加速主前端构建:
|
||||
构建脚本默认使用 Bun:
|
||||
|
||||
```bash
|
||||
# 一次性使用 Bun 构建主前端
|
||||
./build-and-run.sh --bun
|
||||
|
||||
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
|
||||
# 显式指定 Bun
|
||||
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`。
|
||||
|
||||
### 独立官网(Next.js)
|
||||
@@ -592,14 +602,14 @@ BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
|
||||
|
||||
```bash
|
||||
# 首次安装
|
||||
npm --prefix website install
|
||||
bun install --cwd website --frozen-lockfile
|
||||
|
||||
# 本地开发官网
|
||||
npm run website:dev
|
||||
bun run website:dev
|
||||
|
||||
# 类型检查与生产构建
|
||||
npm run website:typecheck
|
||||
npm run website:build
|
||||
bun run website:typecheck
|
||||
bun run website:build
|
||||
```
|
||||
|
||||
`./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
@@ -258,6 +258,21 @@ impl InteractionBroker {
|
||||
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 {
|
||||
let mut entries = self.entries.lock().await;
|
||||
let matches_generation = entries
|
||||
@@ -470,4 +485,20 @@ mod tests {
|
||||
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(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ pub enum ToolCapability {
|
||||
AskUserQuestion,
|
||||
#[serde(rename = "Bash")]
|
||||
Bash,
|
||||
#[serde(rename = "CreateDocument")]
|
||||
CreateDocument,
|
||||
#[serde(rename = "Skill")]
|
||||
Skill,
|
||||
}
|
||||
@@ -31,6 +33,7 @@ impl ToolCapability {
|
||||
Self::Grep => "Grep",
|
||||
Self::AskUserQuestion => "AskUserQuestion",
|
||||
Self::Bash => "Bash",
|
||||
Self::CreateDocument => "CreateDocument",
|
||||
Self::Skill => "Skill",
|
||||
}
|
||||
}
|
||||
@@ -44,6 +47,7 @@ impl ToolCapability {
|
||||
"Grep" => Some(Self::Grep),
|
||||
"AskUserQuestion" => Some(Self::AskUserQuestion),
|
||||
"Bash" => Some(Self::Bash),
|
||||
"CreateDocument" => Some(Self::CreateDocument),
|
||||
"Skill" => Some(Self::Skill),
|
||||
_ => None,
|
||||
}
|
||||
@@ -64,7 +68,7 @@ pub struct ToolSpec {
|
||||
pub approval_can_be_remembered: bool,
|
||||
}
|
||||
|
||||
const SERVER_TOOLS: [ToolSpec; 7] = [
|
||||
const SERVER_TOOLS: [ToolSpec; 8] = [
|
||||
ToolSpec::policy(ToolCapability::Read),
|
||||
ToolSpec::policy(ToolCapability::Write),
|
||||
ToolSpec::policy(ToolCapability::Edit),
|
||||
@@ -72,6 +76,7 @@ const SERVER_TOOLS: [ToolSpec; 7] = [
|
||||
ToolSpec::policy(ToolCapability::Grep),
|
||||
ToolSpec::policy(ToolCapability::AskUserQuestion),
|
||||
ToolSpec::policy(ToolCapability::Bash),
|
||||
ToolSpec::policy(ToolCapability::CreateDocument),
|
||||
];
|
||||
|
||||
const DESKTOP_TOOLS: [ToolSpec; 8] = [
|
||||
@@ -207,7 +212,8 @@ mod tests {
|
||||
"Glob",
|
||||
"Grep",
|
||||
"AskUserQuestion",
|
||||
"Bash"
|
||||
"Bash",
|
||||
"CreateDocument"
|
||||
]
|
||||
);
|
||||
assert!(!policy.allows("Skill"));
|
||||
|
||||
@@ -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;
|
||||
Generated
-12129
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -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
@@ -24,6 +24,7 @@
|
||||
use crate::auth::jwt::validate_jwt;
|
||||
use crate::services::agent_workbench::{
|
||||
bash_sandbox::SandboxedBashTool,
|
||||
document::{output_mime, CreateDocumentTool},
|
||||
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
|
||||
run_registry, sandbox,
|
||||
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 \
|
||||
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 \
|
||||
using a tool that writes files or runs commands. Never attempt to access paths outside \
|
||||
the working directory.";
|
||||
using a tool that writes files or runs commands. Use CreateDocument when the user asks \
|
||||
for a downloadable document, spreadsheet, presentation, PDF, or structured text file. \
|
||||
Never attempt to access paths outside the working directory.";
|
||||
|
||||
static INTERACTIONS: Lazy<InteractionBroker> =
|
||||
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/approve", post(approve_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/models", get(models_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)
|
||||
}
|
||||
};
|
||||
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 =
|
||||
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 app_state = state.clone();
|
||||
let user_id = auth.user_id;
|
||||
let actor_key = auth.actor_key;
|
||||
let agent_cancel = cancel.clone();
|
||||
@@ -1256,6 +1269,9 @@ async fn chat_handler(
|
||||
if tool_policy.allows("Bash") {
|
||||
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 {
|
||||
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 let Some(messages) = latest_transcript.as_ref() {
|
||||
match context_codec.encode_compacted(messages) {
|
||||
@@ -1627,6 +1673,102 @@ async fn forward_sdk_event(
|
||||
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> {
|
||||
crate::db::sql::query_scalar::<_, Option<f64>>(
|
||||
"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(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -1818,10 +1990,16 @@ async fn get_session_handler(
|
||||
})?
|
||||
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
|
||||
.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": true,
|
||||
"session": session,
|
||||
"transcript": transcript,
|
||||
"artifacts": artifacts,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ use std::time::Duration;
|
||||
|
||||
const TEMPLATE_GALLERY_PAGE_URL: &str =
|
||||
"https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html";
|
||||
const LINUX_DO_TEMPLATE_JSON: &str =
|
||||
include_str!("../../assets/picture-agent/linux_do_2036927_prompts_images.with_tags.json");
|
||||
const TEMPLATE_DATA_URL: &str =
|
||||
"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 PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
|
||||
const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
|
||||
@@ -435,10 +437,10 @@ async fn templates_handler(
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||
let limit = query.limit.unwrap_or(144).clamp(1, 240);
|
||||
let (templates, local_status) = match parse_gallery_templates(LINUX_DO_TEMPLATE_JSON, limit) {
|
||||
Ok(templates) => (templates, json!({ "ok": true, "source": "local-json" })),
|
||||
let (templates, remote_status) = match fetch_gallery_templates(&state.http, limit).await {
|
||||
Ok(templates) => (templates, json!({ "ok": true, "source": "remote-json" })),
|
||||
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 }))
|
||||
}
|
||||
};
|
||||
@@ -446,17 +448,43 @@ async fn templates_handler(
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"sourceUrl": TEMPLATE_GALLERY_PAGE_URL,
|
||||
"source": local_status,
|
||||
"source": remote_status,
|
||||
"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> {
|
||||
if limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
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
|
||||
.into_iter()
|
||||
|
||||
@@ -234,7 +234,7 @@ pub(super) async fn workflow_ui_index(State(state): State<AppState>) -> Html<Str
|
||||
<div class="wrap">
|
||||
<div class="box">
|
||||
<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>
|
||||
</body>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
pub mod approval;
|
||||
pub mod ask;
|
||||
pub mod bash_sandbox;
|
||||
pub mod document;
|
||||
pub mod permission;
|
||||
pub mod run_registry;
|
||||
pub mod sandbox;
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn classify_tool_risk(tool_name: &str) -> RiskLevel {
|
||||
|
||||
if matches!(
|
||||
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("edit")
|
||||
|| n.contains("create")
|
||||
@@ -127,6 +127,7 @@ mod tests {
|
||||
"Write",
|
||||
"Edit",
|
||||
"create_file",
|
||||
"CreateDocument",
|
||||
"delete_file",
|
||||
"patch",
|
||||
"ApplyPatch",
|
||||
|
||||
@@ -72,6 +72,24 @@ pub struct RunClaim {
|
||||
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)]
|
||||
pub enum ClaimRunError {
|
||||
#[error("agent session belongs to another actor")]
|
||||
@@ -385,6 +403,59 @@ pub async fn active_run_session(
|
||||
.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
|
||||
/// worker has stopped. The returned boolean is the canonical terminal-event
|
||||
/// ownership flag.
|
||||
@@ -812,6 +883,12 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::sql::query(include_str!(
|
||||
"../../../migrations/20260714000001_agent_run_artifacts.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
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]
|
||||
async fn cancelling_run_keeps_lease_until_worker_finishes() {
|
||||
let db = pool().await;
|
||||
|
||||
@@ -59,6 +59,14 @@ export type AudioResult = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type AgentArtifact = {
|
||||
publicId: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
@@ -66,6 +74,7 @@ export type ChatMessage = {
|
||||
tools: ToolCall[];
|
||||
creativeJob?: CreativeJob;
|
||||
audioResult?: AudioResult;
|
||||
artifacts?: AgentArtifact[];
|
||||
};
|
||||
|
||||
const TERMINAL_JOB_STATUSES = new Set([
|
||||
@@ -247,12 +256,43 @@ export function AgentMessage({
|
||||
/>
|
||||
)}
|
||||
{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} />)}
|
||||
</div>
|
||||
</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' }) {
|
||||
const src = useAuthedMediaUrl(audience, result.url);
|
||||
return (
|
||||
|
||||
@@ -2839,6 +2839,54 @@
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
parseAgentSseFrame,
|
||||
readAgentSse,
|
||||
} 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');
|
||||
|
||||
@@ -83,4 +83,40 @@ describe('AgentWorkbench session restoration', () => {
|
||||
expect(normalizePermissionMode('full-access')).toBe('full_access');
|
||||
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) && (');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import {
|
||||
AgentMessage,
|
||||
isTerminalJobStatus,
|
||||
type AgentArtifact,
|
||||
type ChatMessage,
|
||||
type CreativeJob,
|
||||
type CreativeJobRequestSnapshot,
|
||||
@@ -100,6 +101,27 @@ type AgentSession = {
|
||||
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 = {
|
||||
value: CreationMode;
|
||||
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 {
|
||||
return {
|
||||
id: job.id,
|
||||
@@ -1660,6 +1701,8 @@ export default function AgentWorkbench() {
|
||||
case 'result':
|
||||
flushAgentDeltas();
|
||||
setCancellingRun(false);
|
||||
activeRunIdRef.current = null;
|
||||
setActiveRunId(null);
|
||||
{
|
||||
const eventUsage = d.usage && typeof d.usage === 'object'
|
||||
? d.usage as Record<string, unknown>
|
||||
@@ -1671,11 +1714,17 @@ export default function AgentWorkbench() {
|
||||
costUsd: normalizeNumber(eventUsage.cost_usd, eventUsage.costUsd),
|
||||
numTurns: normalizeNumber(eventUsage.num_turns, eventUsage.numTurns),
|
||||
});
|
||||
const artifacts = normalizeAgentArtifacts(d.artifacts);
|
||||
if (artifacts.length > 0) {
|
||||
updateAssistant((m) => { m.artifacts = artifacts; });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
flushAgentDeltas();
|
||||
setCancellingRun(false);
|
||||
activeRunIdRef.current = null;
|
||||
setActiveRunId(null);
|
||||
toast.error(String(d.message ?? d.error ?? '执行出错'));
|
||||
updateAssistant((m) => {
|
||||
m.text += `\n\n[错误: ${String(d.message ?? d.error ?? '未知错误')}]`;
|
||||
@@ -1684,6 +1733,8 @@ export default function AgentWorkbench() {
|
||||
case 'cancelled':
|
||||
flushAgentDeltas();
|
||||
setCancellingRun(false);
|
||||
activeRunIdRef.current = null;
|
||||
setActiveRunId(null);
|
||||
updateAssistant((m) => {
|
||||
if (!m.text.includes('[已取消]')) m.text += '\n\n[已取消]';
|
||||
});
|
||||
@@ -1691,6 +1742,8 @@ export default function AgentWorkbench() {
|
||||
case 'interrupted':
|
||||
flushAgentDeltas();
|
||||
setCancellingRun(false);
|
||||
activeRunIdRef.current = null;
|
||||
setActiveRunId(null);
|
||||
updateAssistant((m) => {
|
||||
if (!m.text.includes('[运行已中断]')) m.text += '\n\n[运行已中断]';
|
||||
});
|
||||
@@ -1708,6 +1761,54 @@ export default function AgentWorkbench() {
|
||||
[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 rawPrompt = input.trim();
|
||||
const attachmentText = attachments.length > 0
|
||||
@@ -1869,12 +1970,28 @@ export default function AgentWorkbench() {
|
||||
updateAssistant((m) => {
|
||||
m.text += '\n\n[已停止]';
|
||||
});
|
||||
} else {
|
||||
const runId = activeRunIdRef.current;
|
||||
let recovered: AgentRunStatus | null = null;
|
||||
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 {
|
||||
flushAgentDeltas();
|
||||
abortRef.current = null;
|
||||
@@ -1882,14 +1999,14 @@ export default function AgentWorkbench() {
|
||||
setCancellingRun(false);
|
||||
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 controller = abortRef.current;
|
||||
const runId = activeRunIdRef.current;
|
||||
if (!controller || cancellingRun) return;
|
||||
if (cancellingRun || (!controller && !runId)) return;
|
||||
if (!runId) {
|
||||
controller.abort();
|
||||
controller?.abort();
|
||||
return;
|
||||
}
|
||||
setCancellingRun(true);
|
||||
@@ -1972,6 +2089,11 @@ export default function AgentWorkbench() {
|
||||
});
|
||||
if (controller.signal.aborted || restoreAbortRef.current !== controller) return;
|
||||
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);
|
||||
activeRunIdRef.current = null;
|
||||
setActiveRunId(null);
|
||||
@@ -2008,6 +2130,36 @@ export default function AgentWorkbench() {
|
||||
[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 activeModelLabel = useMemo(() => {
|
||||
if (creationMode === 'agent') {
|
||||
@@ -2328,7 +2480,7 @@ export default function AgentWorkbench() {
|
||||
<em>模型与参数</em>
|
||||
</span>
|
||||
</div>
|
||||
{running && (
|
||||
{(running || activeRunId) && (
|
||||
<button className="agent-workbench-stop-top" type="button" onClick={stop} disabled={cancellingRun}>
|
||||
{cancellingRun ? '取消中...' : '停止'}
|
||||
</button>
|
||||
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"v":1}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "bab1b022bd56a64f9a08b46b36b97c5cff19d77e"
|
||||
},
|
||||
"path_in_vcs": "sqlx-core"
|
||||
}
|
||||
-92
@@ -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
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"v":1}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "bab1b022bd56a64f9a08b46b36b97c5cff19d77e"
|
||||
},
|
||||
"path_in_vcs": "sqlx-mysql"
|
||||
}
|
||||
-79
@@ -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
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"v":1}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "bab1b022bd56a64f9a08b46b36b97c5cff19d77e"
|
||||
},
|
||||
"path_in_vcs": "sqlx-sqlite"
|
||||
}
|
||||
-77
@@ -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"]
|
||||
Reference in New Issue
Block a user