fix: route video agent to comfyui and ignore openspec

This commit is contained in:
boos
2026-05-21 16:44:30 +02:00
parent 9c84759498
commit d0912e44b4
31 changed files with 350 additions and 1932 deletions
+1
View File
@@ -19,4 +19,5 @@ docker/.env
.comfyui/
.claude/
.codex/
openspec/
boosapi
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-20
@@ -1,220 +0,0 @@
## Context
The frontend `/routes` page is end-to-end broken: `POST /api/routes/rebuild`
in `src/routes/admin/compat.rs:368-370` was a stub that returned
`{ success: true }` without touching `token_routes` / `route_channels`, and
`GET /api/models/token-candidates` was unregistered. Sites and accounts
configure cleanly, models populate `model_availability` via the existing
`POST /api/accounts/{id}/models/refresh` path, but nothing ever fanned that
out into route rows, so `TokenRoutes.tsx` rendered "暂无路由" no matter
what.
The proposal calls for three capabilities — `route-model-discovery`,
`route-rebuild-pipeline`, `route-candidate-api` — plus an auto-trigger
hook on account creation. This document records what was actually built
and the trade-offs we accepted relative to the proposal.
**Stakeholders:**
- Backend (this change): Rust handler authors maintaining `routes::admin`.
- Frontend (`TokenRoutes.tsx:411-412`, `:252`): consumes the rebuild
summary and the candidates payload — contracts are load-bearing.
- Operators: re-runs `POST /api/routes/rebuild` whenever model topology
shifts; relies on `manual_override = 1` channels surviving rebuilds.
**Constraints:**
- SQLite as the backing store; no schema migrations in this change.
- Existing `model_availability` table is the source of truth for model
presence — already filled by the unmodified
`POST /api/accounts/{id}/models/refresh` handler.
- `manual_override = 1` rows in `route_channels` are user-curated and
must survive every rebuild, untouched.
## Goals / Non-Goals
**Goals:**
- Turn `POST /api/routes/rebuild` into a real pipeline that reads
`model_availability` (filtered by `accounts.status` and
`site_disabled_models`) and writes `token_routes` + `route_channels`.
- Return a `RebuildSummary` whose camelCase fields match the
`TokenRoutes.tsx:411-412` toast contract (`createdRoutes`,
`createdChannels`).
- Register `GET /api/models/token-candidates` returning the four-field
shape `TokenRoutes.tsx:252` already calls.
- Fire a background rebuild after account creation so first-time users
don't have to click "重建" before the page works.
- Preserve `display_name` / `display_icon` / `routing_strategy` on
re-runs — manual edits on `token_routes` must not be clobbered.
**Non-Goals:**
- Live `/v1/models` discovery (`route-model-discovery` capability). The
proposal sketched per-platform fetchers (OpenAI / Anthropic / generic)
and a per-`(site, account)` cache. We did not build them. Reason: the
existing `model_availability` table is already filled by the refresh
endpoint, and "就用现成的" was the explicit call. Re-scoped as a
potential follow-up change.
- `modelsMissingTokenGroups` and `endpointTypesByModel` content. The
legacy Node handler computes these from a pricing-catalog fetcher and
a marketplace-cache file that don't exist in this codebase. Returning
`{}` keeps the contract intact; the frontend tolerates empty objects.
- New crates. We reused `sqlx`, `tokio`, and `axum` already in the tree.
## Decisions
### D1. Read from `model_availability` instead of fetching upstream
**Choice:** The rebuild reads `model_availability ⋈ accounts` (filtered
by `accounts.status = 'active'` and an `EXISTS NOT` against
`site_disabled_models`).
**Alternatives considered:**
- Build the per-platform fetcher trait the proposal described and call
it inline during rebuild. Rejected: doubles the scope, ads network
failure modes to a write-path that should be deterministic, and the
table is already kept current by the refresh endpoint.
- Fetch on-demand only when `model_availability` is empty for an
account. Rejected: hard to reason about — user can't tell whether a
rebuild used cached or fresh data.
**Rationale:** Separation of concerns. "Refresh models for an account"
is one job (already implemented); "rebuild routes from known models"
is another. Composing them via the table keeps each path testable.
### D2. Single transaction for the whole rebuild
**Choice:** `state.db.begin()` at the top, `tx.commit()` at the bottom.
Every read and write goes through `&mut *tx`.
**Alternatives:**
- Per-route transaction so partial failures still produce some routes.
Rejected: `token_routes` and `route_channels` get out of sync if we
abort halfway, which is exactly the bug we're fixing.
**Rationale:** SQLite write throughput is dominated by fsync, not lock
contention; one big transaction is faster than many small ones for the
sizes we expect (tens to low hundreds of accounts). Rebuild is rare
enough that holding the write lock for the duration is acceptable.
### D3. Reuse existing `token_routes` rows by `model_pattern`
**Choice:** Pre-load `(model_pattern → id)` into a `HashMap`, look up
each model name in the map, only `INSERT` when absent.
**Alternatives:**
- `INSERT ... ON CONFLICT DO NOTHING` and then `SELECT` the id back.
Rejected: requires a unique index on `model_pattern` we didn't want
to add as a side effect; also two round-trips per model.
- Truncate-and-rewrite. Rejected: drops user-edited
`display_name`/`display_icon`.
**Rationale:** O(1) lookup per model, one query upfront, manual edits
preserved.
### D4. `manual_override = 1` channels are sacred
**Choice:** When reconciling channels under a route, partition existing
rows into `manual_keys` (skipped entirely) and `auto_by_key`
(reconciled). The desired-set excludes any `(account, model)` covered
by a manual row, so we never insert an auto row that would shadow a
manual one.
**Rationale:** Operators set `manual_override = 1` precisely to opt out
of automation. Stomping it would erase intent.
### D5. `token_id` selection is best-effort, nullable-tolerant
**Choice:** When inserting a new `route_channels` row, pick the
account's default-flagged enabled token via
`ORDER BY COALESCE(is_default, 0) DESC, id ASC LIMIT 1`. If the account
has no tokens, store `NULL` and let the proxy layer's existing
"account-level token" fallback handle it at request time.
**Alternatives:**
- Skip channels for accounts with no tokens. Rejected: the proposal
surfaces these as `modelsWithoutToken` in the candidates API; they
should still appear in the route list so the operator sees them.
- Loop over every enabled token and create one channel per token.
Rejected: explodes `route_channels` and conflates "account routing"
with "token routing", which the schema treats as separate concerns.
### D6. Auto-rebuild via fire-and-forget `tokio::spawn`
**Choice:** `spawn_route_rebuild(state.clone())` after a successful
account insert in both the single-account and batch branches of
`POST /api/accounts`. Errors are logged at `warn`, the response is not
delayed.
**Alternatives:**
- Synchronous rebuild in the request path. Rejected: adds rebuild
latency to every account creation, including ones where models
haven't been refreshed yet (so the rebuild would be a no-op). Worst
case: a slow rebuild times out the HTTP request.
- Job queue / cron. Rejected: introduces infrastructure for a single
trigger; we already have the `/api/routes/rebuild` endpoint as the
manual fallback.
**Rationale:** The user's actual flow is "add account → refresh models
→ rebuild fires → routes appear". Auto-trigger handles step 3 without
blocking step 1.
### D7. Token-candidates returns two real fields, two empty objects
**Choice:** `models` and `modelsWithoutToken` are computed from
`model_availability ⋈ account_tokens ⋈ accounts ⋈ sites`.
`modelsMissingTokenGroups` and `endpointTypesByModel` are returned as
`{}`.
**Rationale:** Legacy Node behavior depends on
`fetchModelPricingCatalog` and `readModelsMarketplaceCache`, neither of
which has a Rust counterpart in this tree. Returning empty objects
satisfies the schema; the UI degrades gracefully (no missing-group
warnings, no per-model endpoint chips). When/if a marketplace cache
lands, this handler is the place to wire it in.
## Risks / Trade-offs
- **[Stale `model_availability`]** → A rebuild reflects whatever was in
the table at run time. Mitigation: the existing
`POST /api/accounts/{id}/models/refresh` is the contract for
freshness; the auto-rebuild fires after account create, and the
manual rebuild button covers re-runs.
- **[Long rebuild blocks writes]** → A single transaction holds the
write lock for the duration of the rebuild. For tens of accounts and
hundreds of models, expected runtime is sub-second. If the install
scales past low thousands of channels, switch D2 to per-route
transactions or move the rebuild to a background worker.
- **[Auto-rebuild noise]** → Fires on every account create, even when
`model_availability` for that account is still empty (rebuild becomes
a no-op writing nothing). Acceptable: cost is one short transaction
with empty desired sets. Logged at `info` so operators can see it.
- **[Empty `endpointTypesByModel`]** → Frontend can't show endpoint
chips. Cosmetic; not blocking. Documented as a non-goal so
follow-ups know where to plug the marketplace cache.
- **[`manual_override` semantics depend on `BOOLEAN` storage]** →
SQLite stores booleans as 0/1 integers. We decode via `Option<bool>`
and `unwrap_or(false)`; rows with `NULL` are treated as auto. Matches
schema default.
## Migration Plan
No schema changes. Deploy is a code-only swap:
1. Ship the new binary. The stub handler is replaced atomically; first
request after the swap exercises the new pipeline.
2. Run `POST /api/routes/rebuild` once after deploy to backfill
existing installs whose `model_availability` is already populated.
3. New account creations from this point on auto-rebuild.
**Rollback:** revert the binary. The new code never wrote to a column
or table that didn't already exist, and `manual_override` rows are
untouched, so rollback restores the stub without data loss.
## Open Questions
- Should `route-model-discovery` ship as a separate change, or is
"refresh endpoint fills the table" considered the permanent design?
Tracked outside this change for now.
- Do we want a debounce on the auto-rebuild when batch-creating many
accounts in quick succession? Current behavior fires once per
`POST /api/accounts` request (covers batch via the multi-token
branch), which is fine. If callers ever script many sequential
single-account POSTs, consider coalescing.
@@ -1,46 +0,0 @@
## Why
前端 `/routes` 页面打开后路由列表完全空白,根因是后端 `POST /api/routes/rebuild` 是个空 stub(`src/routes/admin/compat.rs:368-370` 只回 `success: true`),`/api/models/token-candidates` 端点根本没注册。即使站点和账号都配好了,`token_routes` / `route_channels` 这两张表也没人去填,前端只能渲染"暂无路由"。要让路由功能从配好账号到出现在列表里端到端可用,必须把"扫上游模型 → 过滤 → 写表"这条流水线在后端补齐。
## What Changes
- **新增**:多平台模型发现(OpenAI 兼容 / Anthropic / 自定义),从站点 `url + api_key + custom_headers` 拉 `/v1/models`,结果按 `(site_id, account_id)` 缓存并尊重 `site_disabled_models` 黑名单。
- **新增**:`POST /api/routes/rebuild` 真实实现 — 扫描所有 active 账号,按 `model_name` 聚合为一条 `token_routes` 记录,每个 `(account, source_model)` 落一条 `route_channels` 记录,多账号自动合并到同一 route 下。响应体包含 `rebuild.createdRoutes` / `createdChannels` 以满足 `TokenRoutes.tsx:411-412` 的现有契约。
- **新增**:`GET /api/models/token-candidates` 端点 — 返回 `{ models, modelsWithoutToken, modelsMissingTokenGroups, endpointTypesByModel }`,给前端"候选模型"面板和新建路由弹窗的下拉用(`TokenRoutes.tsx:252` 已经在调)。
- **新增**:每次"账号成功添加"后由 service 层异步触发一次 rebuild,避免用户必须手点重建按钮。
- **修改**:`compat.rs` 中保留的 `rebuild_routes` 占位 handler 删除,迁移到独立 service 模块。
- 不破坏已有 `GET /api/routes` / `/lite` / `/summary` 与 `route_channels` 写表 schema。
## Capabilities
### New Capabilities
- `route-model-discovery`: 站点级模型清单的获取、缓存与黑名单过滤。包含多平台 fetcher trait(OpenAI / Anthropic / 自定义 header)、`site_disabled_models` 应用、超时与重试策略、按账号 credential mode(`apikey` / oauth)选取认证头。
- `route-rebuild-pipeline`: 把发现到的模型合成路由的写库流水线。负责创建/更新 `token_routes`(按模型名聚合)、为每个 `(account, source_model)` 创建 `route_channels`、保留手工 `manual_override = 1` 通道、回滚已下线模型对应的通道。同步回填 `rebuild.createdRoutes` / `createdChannels` 字段。
- `route-candidate-api`: `GET /api/models/token-candidates` 端点。基于 `route-model-discovery` 的缓存返回前端需要的 4 个聚合字段,区分"账号有 token 能用"、"账号在但没 token"、"账号 token 缺组"三种状态。
### Modified Capabilities
无(`openspec/specs/` 当前为空,本仓库还没有沉淀过 spec,所以全部走新建)。
## Impact
**新增代码:**
- `src/services/model_discovery/` — 模型 fetcher trait + 三个 impl(openai / anthropic / generic),缓存层。
- `src/services/route_rebuild/` — 重建流水线。
- `src/routes/admin/model_candidates.rs` — `GET /api/models/token-candidates` handler。
- `src/routes/admin/compat.rs` — 把 `rebuild_routes` 改写为调用新 service。
**接触的表:**
- 读:`sites`, `accounts`, `account_tokens`, `site_disabled_models`
- 写:`token_routes`, `route_channels`
**外部依赖:**
- 复用现有 `reqwest` 客户端 + `tokio` runtime,不引入新 crate。
- 复用 `state.config.proxy.upstream_timeout_secs` 做模型拉取超时。
**前端:**无需改动。`TokenRoutes.tsx` 和 `api.ts` 当前调用契约保持不变,列表自然出现。
**风险:**
- 模型扫描走外网,慢账号会拖时间。流水线必须 per-site 并发 + 单 site 超时。
- `route_channels` 已有数据时不能粗暴 truncate,需保留 `manual_override = 1` 的人工通道。
@@ -1,88 +0,0 @@
## ADDED Requirements
### Requirement: `GET /api/models/token-candidates` is registered
The admin router SHALL register `GET /api/models/token-candidates`
behind the standard admin auth layer. The handler SHALL return a JSON
object with exactly four top-level keys: `models`,
`modelsWithoutToken`, `modelsMissingTokenGroups`, `endpointTypesByModel`.
#### Scenario: Endpoint returns 200 with the four-key shape
- **WHEN** an authenticated admin sends `GET /api/models/token-candidates`
- **THEN** the response is HTTP 200 with a JSON body containing keys
`models`, `modelsWithoutToken`, `modelsMissingTokenGroups`, and
`endpointTypesByModel` — no extra top-level keys, no missing keys
#### Scenario: Unauthenticated request is rejected
- **WHEN** a request without a valid admin token reaches the endpoint
- **THEN** the admin auth layer returns 401 before the handler runs
### Requirement: `models` lists every (model, token) candidate
The `models` field SHALL be an object whose keys are model names and
whose values are arrays of candidate descriptors. A `(model, token)`
combination SHALL appear in the `models` array for that model exactly
when ALL of the following hold:
- a row in `model_availability` reports `available = 1` for the model
on the token's account
- the token's `enabled` is truthy (NULL treated as enabled)
- the token's `value_status` is `'ready'` (NULL treated as ready)
- the account's `status = 'active'`
- the site's `status` is `'active'` (NULL treated as active)
Each candidate descriptor SHALL contain: `accountId`, `tokenId`,
`tokenName`, `isDefault` (boolean), `username`, `siteId`, `siteName`.
Within a single model's array, descriptors SHALL be deduplicated by
`tokenId`.
#### Scenario: Active token with available model appears
- **WHEN** account 5 (active, site active) has token 11 (enabled,
ready) and `model_availability(account=5, model='gpt-4')` is
available
- **THEN** `models['gpt-4']` includes a descriptor with
`accountId = 5, tokenId = 11`
#### Scenario: Disabled token is excluded
- **WHEN** account 5 has token 11 with `enabled = 0`
- **THEN** no descriptor with `tokenId = 11` appears in `models` for
any model
#### Scenario: Duplicate tokens in source data deduped
- **WHEN** the underlying join produces two rows for the same
`(model, tokenId)` pair (e.g., joins fan out)
- **THEN** the descriptor appears exactly once in
`models[model_name]`
### Requirement: `modelsWithoutToken` surfaces accounts missing tokens
The `modelsWithoutToken` field SHALL be an object keyed by model name.
For each `(account, model)` combination where the model is available
on the active account but no qualifying token (per the `models`
requirement) exists for that account, the handler SHALL include a
descriptor `{ accountId, username, siteId, siteName }` under that
model name. Descriptors SHALL be deduplicated by `accountId` within
each model's array.
#### Scenario: Account with available model but no enabled tokens
- **WHEN** account 8 reports `gpt-4` available but has zero rows in
`account_tokens` (or all rows are disabled)
- **THEN** `modelsWithoutToken['gpt-4']` includes a descriptor with
`accountId = 8`, and that account does NOT appear in `models['gpt-4']`
#### Scenario: Account with token does not appear here
- **WHEN** account 5 reports `gpt-4` available and has a usable token
- **THEN** account 5 does NOT appear in `modelsWithoutToken['gpt-4']`
### Requirement: `modelsMissingTokenGroups` and `endpointTypesByModel` are empty
The handler SHALL return `modelsMissingTokenGroups` and
`endpointTypesByModel` as empty JSON objects (`{}`). The frontend
contract permits empty objects and these fields SHALL NOT be
populated until a marketplace-cache or pricing-catalog source is
added to the backend.
#### Scenario: Both fields are empty objects
- **WHEN** the handler returns successfully under any state of the
database
- **THEN** `response.modelsMissingTokenGroups === {}` and
`response.endpointTypesByModel === {}`
@@ -1,153 +0,0 @@
## ADDED Requirements
### Requirement: Route rebuild reads from `model_availability`
The rebuild pipeline SHALL derive its working set from the
`model_availability` table joined to `accounts`, filtering out:
- accounts whose `status` is not `'active'`
- rows whose `available` is not `1` (treating `NULL` as not available)
- `(account, model)` combinations covered by `site_disabled_models`
for the account's `site_id`
The pipeline SHALL NOT make outbound HTTP requests to upstream platforms
during a rebuild. Refreshing `model_availability` is the responsibility
of the existing `POST /api/accounts/{id}/models/refresh` endpoint and
is out of scope for this requirement.
#### Scenario: Inactive account is excluded
- **WHEN** an account with `status = 'suspended'` has rows in
`model_availability`
- **THEN** no `token_routes` row is created on its behalf and no
`route_channels` row references its `account_id`
#### Scenario: Site-disabled model is excluded
- **WHEN** `site_disabled_models` contains `(site_id = 7, model_name = 'gpt-x')`
and an active account on site 7 has `model_availability` for `gpt-x`
- **THEN** the rebuild does not include that account in the channel set
for the `gpt-x` route
### Requirement: Rebuild aggregates one route per distinct model name
The rebuild SHALL ensure exactly one row exists in `token_routes` for
every distinct `model_name` in the filtered working set, keyed by
`model_pattern = model_name`. The rebuild SHALL reuse existing rows
when their `model_pattern` already matches; new rows SHALL be inserted
with `routing_strategy = 'weighted'`, `route_mode = 'pattern'`, and
`enabled = 1`.
The rebuild MUST NOT overwrite `display_name`, `display_icon`, or any
other fields on existing `token_routes` rows. Operators rely on these
fields being editable from the frontend without being clobbered on
re-runs.
#### Scenario: New model creates a new route
- **WHEN** model `claude-4-opus` appears in the working set and no
`token_routes` row has `model_pattern = 'claude-4-opus'`
- **THEN** the rebuild inserts a `token_routes` row with
`model_pattern = 'claude-4-opus'`, `routing_strategy = 'weighted'`,
`route_mode = 'pattern'`, `enabled = 1`
#### Scenario: Existing route is reused without overwrite
- **WHEN** a `token_routes` row exists with
`model_pattern = 'gpt-4'` and `display_name = 'GPT-4 Turbo (Custom)'`
- **THEN** the rebuild leaves `display_name` unchanged and reuses the
same `id` for channel reconciliation
### Requirement: Channel reconciliation preserves manual overrides
For each route, the rebuild SHALL reconcile `route_channels` rows
keyed by `(account_id, source_model)` against the desired set derived
from `model_availability`. Rows with `manual_override = 1` SHALL be
treated as immutable: not inserted, not updated, not deleted. Rows
with `manual_override = 0` (or `NULL`) SHALL be:
- inserted when missing from the existing set
- hard-deleted when no longer in the desired set
The desired set SHALL exclude any `(account, model)` combination
already covered by a `manual_override = 1` row, so the rebuild never
inserts an auto channel that shadows a manual one.
#### Scenario: Manual channel survives rebuild
- **WHEN** a `route_channels` row has `manual_override = 1` and its
`(account_id, source_model)` is no longer in the desired set
- **THEN** the row is left untouched and counted in
`RebuildSummary.skipped_manual_channels`
#### Scenario: Stale auto channel is deleted
- **WHEN** a `route_channels` row has `manual_override = 0`,
`account_id = 12`, `source_model = 'old-model'`, and no longer
appears in the desired set
- **THEN** the row is deleted and counted in
`RebuildSummary.deleted_channels`
#### Scenario: New auto channel is inserted with default token
- **WHEN** account 12 newly reports `gpt-5` available and has at least
one enabled `account_tokens` row
- **THEN** a new `route_channels` row is inserted with `route_id`
pointing at the `gpt-5` route, `account_id = 12`, the
default-flagged enabled token's id (or the lowest id when none is
default), `priority = 0`, `weight = 10`, `enabled = 1`,
`manual_override = 0`
#### Scenario: Account without tokens still gets a channel
- **WHEN** account 13 reports `gpt-5` available but has no enabled
`account_tokens` rows
- **THEN** a `route_channels` row is inserted with `token_id = NULL`,
relying on the proxy layer's account-level token fallback
### Requirement: Rebuild runs in a single transaction
The rebuild SHALL execute every read and write inside one database
transaction, committed at the end. A partial failure MUST NOT leave
`token_routes` and `route_channels` mutually inconsistent.
#### Scenario: Failure mid-rebuild rolls back
- **WHEN** an `INSERT` into `route_channels` fails partway through
the rebuild
- **THEN** the transaction aborts and no `token_routes` insert from
the same rebuild persists
### Requirement: Rebuild summary matches frontend contract
The rebuild SHALL return a `RebuildSummary` containing the fields
`createdRoutes`, `reusedRoutes`, `createdChannels`, `deletedChannels`,
`skippedManualChannels`, `scannedModels`, `scannedAccounts` (camelCase
when serialized). The `POST /api/routes/rebuild` handler SHALL embed
this object under the JSON key `rebuild`, alongside `success: true`.
`TokenRoutes.tsx:411-412` reads `rebuild.createdRoutes` and
`rebuild.createdChannels`; both MUST be present and SHALL count only
rows actually inserted by the current rebuild (not pre-existing rows
that were reused).
#### Scenario: Rebuild response shape
- **WHEN** the handler completes successfully
- **THEN** the response body is
`{ "success": true, "rebuild": { "createdRoutes": <n1>,
"reusedRoutes": <n2>, "createdChannels": <n3>,
"deletedChannels": <n4>, "skippedManualChannels": <n5>,
"scannedModels": <n6>, "scannedAccounts": <n7> } }`
#### Scenario: Reused route does not increment createdRoutes
- **WHEN** every model in the working set has a pre-existing
`token_routes` row
- **THEN** `createdRoutes = 0` and `reusedRoutes` equals the number of
distinct models scanned
### Requirement: Auto-rebuild after account creation
The system SHALL fire one route rebuild in the background after
`POST /api/accounts` returns success for at least one created
account. The HTTP response SHALL return without waiting for the
rebuild to finish. Errors from the background rebuild SHALL be logged
at `warn` level and SHALL NOT affect the response status or body.
#### Scenario: Single-account create triggers rebuild
- **WHEN** `POST /api/accounts` creates exactly one account
- **THEN** `spawn_route_rebuild` is invoked once with the cloned
`AppState` and the response returns immediately
#### Scenario: Batch-create with all failures does not trigger
- **WHEN** `POST /api/accounts` returns with `created.len() == 0` and
`failed.len() > 0`
- **THEN** `spawn_route_rebuild` is not invoked
@@ -1,52 +0,0 @@
## 1. Service module
- [x] 1.1 Create `src/services/route_rebuild.rs` with `RebuildSummary` struct (camelCase serde) and `pub async fn rebuild_routes(&AppState) -> AppResult<RebuildSummary>`
- [x] 1.2 Implement transactional pipeline: open `state.db.begin()`, do all reads/writes through `&mut *tx`, commit at the end
- [x] 1.3 Query `model_availability ⋈ accounts` filtering by `accounts.status = 'active'`, `available = 1`, and `NOT EXISTS site_disabled_models`
- [x] 1.4 Group rows by model name into `HashMap<String, Vec<i64>>` and pre-load existing `(model_pattern, id)` from `token_routes` for reuse
- [x] 1.5 Insert new `token_routes` rows with `routing_strategy = 'weighted'`, `route_mode = 'pattern'`, `enabled = 1`; never overwrite existing display fields
- [x] 1.6 Per route, partition existing `route_channels` rows into `manual_keys` (skip) vs `auto_by_key` (reconcile)
- [x] 1.7 Insert missing auto channels with default-token pick (`ORDER BY COALESCE(is_default,0) DESC, id ASC LIMIT 1`); allow `token_id = NULL` when no tokens exist
- [x] 1.8 Hard-delete auto channels that fell out of the desired set; count into `RebuildSummary.deleted_channels`
- [x] 1.9 Expose the module from `src/services/mod.rs` (`pub mod route_rebuild;`)
## 2. Rebuild HTTP handler
- [x] 2.1 Replace the stub `rebuild_routes()` in `src/routes/admin/compat.rs:368` with a handler that takes `State<AppState>` and calls `services::route_rebuild::rebuild_routes`
- [x] 2.2 Return `Json(json!({ "success": true, "rebuild": summary }))` so `TokenRoutes.tsx:411-412` reads `rebuild.createdRoutes` / `createdChannels`
## 3. Token-candidates handler
- [x] 3.1 Create `src/routes/admin/model_candidates.rs` with `pub async fn token_candidates(State<AppState>) -> AppResult<Json<Value>>`
- [x] 3.2 Query `(model, account, token, site)` join filtering active sites/accounts, enabled tokens, `value_status = 'ready'`; build `models` map deduped by `tokenId` per model
- [x] 3.3 Query `(model, account, site)` join for available models on active accounts; diff against the covered set to populate `modelsWithoutToken` deduped by `accountId` per model
- [x] 3.4 Return all four keys: `models`, `modelsWithoutToken`, `modelsMissingTokenGroups: {}`, `endpointTypesByModel: {}`
- [x] 3.5 Mount `GET /api/models/token-candidates` under the admin auth layer in `src/routes/admin/mod.rs`
## 4. Auto-rebuild after account create
- [x] 4.1 Add `fn spawn_route_rebuild(state: AppState)` helper at the bottom of `src/routes/admin/accounts.rs` that `tokio::spawn`s the rebuild and logs the summary at `info`, errors at `warn`
- [x] 4.2 Call `spawn_route_rebuild(state.clone())` in the single-account success branch of `create_account`
- [x] 4.3 Call `spawn_route_rebuild(state.clone())` in the batch branch when `created.len() > 0`; skip when no accounts were created
## 5. Verification
- [x] 5.1 `cargo build` clean — no warnings on the new code
- [x] 5.2 Confirm `compat.rs:19` route mount still points at the (now non-stub) `rebuild_routes` handler
- [x] 5.3 Confirm `accounts.rs:228` (batch) and `:248` (single) success paths both fire the auto-rebuild
<!--
Descoped (not implemented in this change, see design.md "Non-Goals"):
- `route-model-discovery` capability (per-platform `/v1/models` fetcher
trait with per-(site, account) cache). Deferred per "就用现成的"
instruction; existing `POST /api/accounts/{id}/models/refresh` fills
`model_availability`, so the pipeline reads from there.
- Marketplace pricing-catalog source for `endpointTypesByModel` and
`modelsMissingTokenGroups`. Returning `{}` is the documented contract;
populate when a Rust-side cache lands.
These are tracked as potential follow-up changes, not work owed by this
change.
-->
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-19
@@ -1,143 +0,0 @@
## Context
`src/routes/comfyui_agent.rs` 第 79-91 行的 `upstream_config()` 用 `BOOSAPI_UPSTREAM_BASE` env(默认 `https://api.boos.lat/v1`)+ `BOOSAPI_UPSTREAM_KEY` env(默认 `state.config.auth.proxy_token`)拼出 base/key,三个 handler(chat 第 351 行、images 第 274 行、audio 第 311 行)都直接用 `reqwest::Client` post 到这个外部地址。这导致:
- ComfyUI Agent 流量绕过本进程已实现的 token_router / capability / proxy_logs / 计费。
- 部署必须保证 `api.boos.lat` 可达,否则功能不可用。
- 本服务自己就是 OpenAI 兼容网关,"BoosAPI 调远端 BoosAPI" 是反直觉的部署形态。
数据库现状(`migrations/20260516000001_initial.sql`):
- `downstream_api_keys` 表已存在,`user_id INTEGER REFERENCES users(id)` 可空(无 NOT NULL 约束)。
- `key` 列上有 `downstream_api_keys_key_unique` 唯一索引。
- `settings` 表是 `(key TEXT PRIMARY KEY, value TEXT)`,可放任意键值。
- `find_downstream_key_by_token` 已能按明文 key 查(`src/db/queries/mod.rs:31`)。
约束:
- 保留现栈(axum + sqlx + reqwest),不引入新依赖。
- 启动顺序在 `src/main.rs`:load config → `db::create_pool` → migrate → `AppState::new` → `state.comfyui.start()` → `build_router`。Builtin key 引导必须在 migrate 之后、router 之前。
- 不破坏既有 `BOOSAPI_UPSTREAM_*` env 在调试场景下的语义。
## Goals / Non-Goals
**Goals:**
- ComfyUI Agent 默认调用 `http://127.0.0.1:{server.port}/v1/{chat/completions, images/generations, audio/speech}`。
- 引入名为 `comfyui-agent` 的内置下游 key,明文持久化到 `settings` 表,重启后保持稳定(不每次换 key)。
- 三类调用(chat / images / audio)行为一致:同一 base + 同一 Bearer key + 同一回退策略。
- `/api/comfyui-agent/status` 暴露 `mode` 与 `keyName`,供前端排障使用。
- 启动时不依赖外部网络。
**Non-Goals:**
- 不为内置 key 增加新的管理界面字段(沿用现有 Downstream Keys 页面,仅做"不可删除"标记)。
- 不重写 token_router / capability,本次只是消费它们。
- 不做请求级动态 key 切换(每次请求都用同一个 builtin key)。
- 不改造 SYSTEM_PROMPT、CHARACTER/SCENE 解析、SSE 转发等业务逻辑。
- 不动 `comfyui_process.rs`(嵌入式 ComfyUI 二进制管理)。
## Decisions
### 1. 内置 key 用现有 `downstream_api_keys` 表 + `user_id IS NULL` 表示系统级
**为什么不另建表**:
- 想要让 builtin key 与普通用户 key 共享同一套计费、限额、proxy_logs 关联(`usage_logs.downstream_key_id REFERENCES downstream_api_keys(id)` 已存在)。
- `user_id` 已经是可空 `INTEGER REFERENCES users(id)`,不需要 schema 改动。
**Alternative 考虑**:
- 单独建一张 `system_api_keys` 表 → 拒绝,要在 `usage_logs` 里加 `system_key_id` 列、改 query,复杂度不成比例。
- 复用 `config.auth.proxy_token` 当 Bearer → 拒绝,proxy_token 与 admin auth_token 是不同语义且不在 `downstream_api_keys` 中,无法计费。
### 2. 密钥明文写到 `settings` 表,key = `builtin_key.comfyui_agent.secret`
启动引导逻辑(`src/services/builtin_keys.rs::ensure_comfyui_agent_key`):
1. `SELECT value FROM settings WHERE key = 'builtin_key.comfyui_agent.secret'`。
2. 若有:在 `downstream_api_keys` 中 `find_downstream_key_by_token(value)`,命中则记 id;未命中则进入步骤 3。
3. 若无(或步骤 2 未命中):生成一个 `sk-comfyui-{uuid_v4_no_hyphen}` 形式的 key,先 `INSERT INTO downstream_api_keys(name='comfyui-agent', key=?, user_id=NULL, enabled=1, description='Built-in key for ComfyUI Agent')`,再 `INSERT OR REPLACE INTO settings(key, value)`。
4. 返回 `(id, secret)` 给 AppState 缓存(`AppState.builtin_keys.comfyui_agent: (i64, String)`)。
整个流程在事务中执行,确保 `settings` 与 `downstream_api_keys` 一致。
**为什么不每次启动重新生成**:
- 每次换 key 会让历史 `proxy_logs` / `usage_logs` 仍然引用旧 id,但客户端无法用旧 key 验证。
- 重启后 key 稳定也方便用户在前端 Downstream Keys 页面持续看到同一行。
**为什么不 hash 存储**:
- 后端调用方就是后端自己,需要在内存中直接持有明文当 Bearer 用;hash 反而要额外解密通道。
- 现有所有 downstream_api_keys 都是明文存储(`find_downstream_key_by_token` 直接 `WHERE key = ?`),保持一致。
### 3. base 解析优先级:env override → 本地拼接
```rust
fn upstream_config(state: &AppState) -> (String, String, AgentMode) {
if let Some(env_base) = std::env::var("BOOSAPI_UPSTREAM_BASE").ok()
.map(|v| v.trim().trim_end_matches('/').to_string())
.filter(|v| !v.is_empty())
{
let key = std::env::var("BOOSAPI_UPSTREAM_KEY").ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| state.builtin_keys.comfyui_agent.1.clone());
return (env_base, key, AgentMode::FallbackRemote);
}
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1"
} else {
&state.config.server.host
};
let base = format!("http://{}:{}/v1", host, state.config.server.port);
let key = state.builtin_keys.comfyui_agent.1.clone();
(base, key, AgentMode::Local)
}
```
**为什么默认本地而不是默认 env**:用户希望 BoosAPI 自包含运行;env 仅作为"我知道我在干什么"的回退。
**为什么不 await async client**:base 解析是纯字符串,每次请求重新算成本可忽略(每次请求最多 3 次调用);不缓存避免与 env 动态变化语义冲突。
### 4. status 端点扩字段,不改路径
`GET /api/comfyui-agent/status` 响应增加:
```json
{
"success": true,
"model": "...",
"upstreamBase": "<resolved>",
"mode": "local" | "fallback-remote",
"keyName": "comfyui-agent",
"comfyui": {...}
}
```
前端可在标题栏显示 mode 徽标,区分本地 / 回退。
### 5. 模型名沿用现配置 `state.config.comfyui.agent_model`
不改 chat 调用的 `model` 字段。如果本地 `/v1/chat/completions` 没有这个模型,会返回 404,让用户去 token_routes 配置;这是正确的失败路径,比 silently fallback 好。
images 仍用 `gpt-image-2`、audio 仍用 `tts-1` —— 与现状一致,本地 token_router 命中即用。
## Risks / Trade-offs
- **[Risk] `127.0.0.1:{port}` 在容器中不可达**:当 server bind `0.0.0.0`,部分容器内 `127.0.0.1` 仍可达 host 进程;但若用户在 Dockerfile 里禁用 loopback,agent 会失败。 → Mitigation:保留 env 回退;status mode 字段帮助排障;文档明确说明。
- **[Risk] settings 表在前端被 `app_settings` admin API 暴露**:明文密钥泄漏给 admin。 → Mitigation:在 settings 读写层加 `builtin_key.*` 前缀过滤,列出 settings 时 mask;本次实施任务覆盖该过滤。
- **[Risk] 前端 Downstream Keys 页面显示 builtin key 的明文**:现有页面会列出明文。 → Mitigation:在 `routes/user_api_keys.rs`(admin list 入口)里把 `name='comfyui-agent' AND user_id IS NULL` 的 key 标记 `system: true` 并打码 key 显示后 4 位;禁止 DELETE/PATCH。
- **[Risk] builtin key 被人为禁用**:admin 在 UI 把 enabled 改为 false,agent 会立即 401。 → Mitigation:启动引导时若发现 enabled=0,主动设回 1 并发 warning;admin API 拒绝对 system key 设 enabled=0。
- **[Risk] 引导事务失败导致 settings 记录与 downstream_api_keys 不一致**:例如 settings 插入后进程崩溃,重启时 token 还在 settings 但表里没行。 → Mitigation:步骤 2 已经包含"settings 有值但表没行"的回填路径;幂等。
- **[Trade-off] 仍保留 `BOOSAPI_UPSTREAM_*` env**:增加一个分支,但提供排障逃生口;用户态文档明确"仅排障使用"。
## Migration Plan
1. **第 1 步**:加 `src/services/builtin_keys.rs` 模块,包含 `ensure_comfyui_agent_key(db: &SqlitePool) -> AppResult<(i64, String)>` 与单测(重复调用幂等、settings 有值但表无行的回填、表内有但 settings 无的恢复)。
2. **第 2 步**:在 `AppState` 增加 `builtin_keys: BuiltinKeys { comfyui_agent: (i64, String) }`;`AppState::new` 改为 async(或在 `main.rs` 中先 ensure 再传入)。
3. **第 3 步**:重写 `comfyui_agent.rs::upstream_config`,新增 `AgentMode` 枚举;三个 handler 改用新返回值;status 多返回 `mode` / `keyName`。
4. **第 4 步**:在 admin 层(`routes/user_api_keys.rs` 或 admin keys 路由)对 `name='comfyui-agent' AND user_id IS NULL` 的行加保护:拒绝 DELETE / 拒绝 enabled=0;list 响应里标 `system=true`。
5. **第 5 步**:在 settings 暴露层(确认有没有 generic settings list API)里过滤 / mask `builtin_key.*` 前缀。
6. **第 6 步**:手测三类调用 + status mode 切换;`AGENTS.md` / `README.md` 更新。
回滚:每步独立 commit;如步骤 3 出问题,revert 即可恢复"默认远端"行为。步骤 1-2 是新增代码,无需回滚。
## Open Questions
- 是否有 generic `/api/admin/settings` 路由会列出 settings 表全部行?需要 `rg "FROM settings"` 在实施第一步前确认;若有,第 5 步改为对该路由加前缀过滤。
- token_router 在没有为 `agent_model` / `gpt-image-2` / `tts-1` 配置路由时的失败模式是 404 还是 500?影响错误文案;实施前在本地起一个空配置环境跑一次。
- builtin key 是否需要支持轮换?本次不做,Open Question 记录后续若有需求再开 spec。
@@ -1,41 +0,0 @@
## Why
`src/routes/comfyui_agent.rs` 目前把 chat / images / audio 三类上游调用全部硬编码走 `https://api.boos.lat/v1`(可被 `BOOSAPI_UPSTREAM_BASE` env 覆盖),完全绕过了本进程已经实现的代理层(token_router、capability、proxy_logs、计费)。这意味着:ComfyUI Agent 的流量看不见、算不上账、用不到本地的智能路由;而本服务自己就是一个 OpenAI 兼容网关,本地 `/v1` 已经能提供 chat/images/audio 三条能力。再继续走外部上游既绕过自家路由又造成"BoosAPI 调 BoosAPI"的奇怪部署形态。
## What Changes
- ComfyUI Agent 默认改为调用本进程自身的 `http://127.0.0.1:{server.port}/v1/*`,复用 token_router 与计费体系。
- 引入一个**内置下游 API key**(name = `comfyui-agent`,user_id 留空表示系统级),仅 ComfyUI Agent 使用,单独计费/限额;启动时若不存在则自动创建并把明文写入 `app_settings`,重启后复用。
- chat / images / audio 三类调用都切到本地 `/v1`,使用上述内置 key 作 Bearer。
- 保留 `BOOSAPI_UPSTREAM_BASE` 为**可选回退**:仅当显式设置且非空时,Agent 才直连远端(用于本地路由不可用的排障场景),并在 `/api/comfyui-agent/status` 响应中明确暴露当前生效的 base 与是否为回退。
- `/api/comfyui-agent/status` 新增字段 `keyName`(始终是 `comfyui-agent`)、`mode`(`local` 或 `fallback-remote`)。
- **BREAKING**(运维侧):当未设置 `BOOSAPI_UPSTREAM_BASE` 时,部署不再需要外部 `api.boos.lat` 网络可达;但同时要求本进程 `server.port` 已绑定到回环且能被 Agent 自身访问。
## Capabilities
### New Capabilities
- `comfyui-agent-local-routing`: ComfyUI Agent 通过本进程 `/v1` 调用 chat / images / audio,使用专属内置下游 key 计费,并提供 `BOOSAPI_UPSTREAM_BASE` 远端回退。
- `builtin-downstream-key`: 系统级(user_id IS NULL)内置下游 API key 的生命周期管理:启动时确保存在、密钥持久化在 `app_settings`、可在 admin API 中可见但不可删除。
### Modified Capabilities
<!-- openspec/specs/ 目前为空,没有既有 spec 受影响。 -->
## Impact
- **后端代码**
- `src/routes/comfyui_agent.rs`:`upstream_config()` 改为从 AppState 拼本地 base + 内置 key;保留 env 回退分支并在响应里暴露 `mode`。
- `src/services/`(或新建 `src/services/builtin_keys.rs`):启动时确保 `comfyui-agent` 内置 key 存在,明文落 `app_settings`。
- `src/db/queries/`:新增 `find_or_create_builtin_downstream_key(name)` / `read_builtin_key_secret(name)`。
- `src/lib.rs` 的 `AppState::new` 之后、`main.rs` 启动序列中调用 builtin key 引导。
- **数据库**
- 复用既有 `downstream_api_keys` 表(user_id 设为 NULL 表示系统级);可能需要一条迁移把 `user_id` 列改为可空(如果已是可空则跳过)。
- 复用既有 `app_settings` / `settings` 键值表存放生成的明文密钥(避免每次重启换 key 让历史日志关联断裂)。
- **观测**
- ComfyUI Agent 流量从此出现在 `proxy_logs` 中,按 key=`comfyui-agent` 可在管理面板筛出。
- **配置 / 文档**
- `AGENTS.md` 与 `README.md` 的"ComfyUI Agent"段落需要更新:默认本地、env 回退、密钥位置。
- **测试**
- 新增端到端测试:mock 一个 `/v1/chat/completions` 路径返回固定 SSE,验证 Agent 命中本地。
- 新增单测:builtin key 引导 idempotent;status 在 env 设置/未设置时分别返回 `local` / `fallback-remote`。
@@ -1,82 +0,0 @@
## ADDED Requirements
### Requirement: 启动时确保内置 ComfyUI Agent key 存在
服务启动序列 SHALL 在数据库迁移完成之后、HTTP 路由对外提供之前,调用 `ensure_comfyui_agent_key`,确保 `downstream_api_keys` 表中存在唯一一行 `name='comfyui-agent' AND user_id IS NULL`,且其明文 key 同步保存在 `settings` 表的 `builtin_key.comfyui_agent.secret` 键。该流程 MUST 幂等。
#### Scenario: 全新数据库首次启动
- **WHEN** 启动时数据库无任何 `name='comfyui-agent'` 行,且 settings 表无 `builtin_key.comfyui_agent.secret`
- **THEN** 启动结束后两处都存在;密钥形如 `sk-comfyui-<32 位十六进制或等价 uuid>`;`enabled=1`
#### Scenario: 已有 settings 但缺少表记录(崩溃恢复)
- **WHEN** settings 表存在 `builtin_key.comfyui_agent.secret = "sk-comfyui-xxx"`,但 `downstream_api_keys` 中没有对应行
- **THEN** 引导逻辑用 settings 中的明文回填一行 `downstream_api_keys`,不重新生成密钥
#### Scenario: 已有表记录但缺少 settings
- **WHEN** `downstream_api_keys` 中存在 `name='comfyui-agent' AND user_id IS NULL` 行 key=`sk-comfyui-yyy`,但 settings 中无 `builtin_key.comfyui_agent.secret`
- **THEN** 引导逻辑把表中的明文写回 settings;密钥保持原值
#### Scenario: 重复启动不变更密钥
- **WHEN** 同一数据库连续启动两次
- **THEN** 第二次启动后 settings 与表中的 key 与第一次完全一致
#### Scenario: 引导期间发现 enabled=0 自动恢复
- **WHEN** 启动时发现已有行 `enabled=0`
- **THEN** 引导逻辑把该行 `enabled` 设为 1,并通过 `tracing::warn!` 输出一条提示
### Requirement: 系统级 key 在管理 API 中受保护
任何 admin 接口 SHALL 拒绝对 `name='comfyui-agent' AND user_id IS NULL` 的下游 key 执行删除(`DELETE`)或将 `enabled` 设为 false。list / get 类响应 MUST 把这一行标记为 `system: true`,并 mask 明文 key(仅暴露后 4 位)。
#### Scenario: DELETE 被拒
- **WHEN** admin 调用 `DELETE` 删除该 key 对应 id
- **THEN** 返回 403 / 409 类错误(取实现选择),message 含 `system key` 关键字;数据库中的行不变
#### Scenario: 禁用被拒
- **WHEN** admin PATCH/PUT 设 `enabled=false`
- **THEN** 返回拒绝错误,行 `enabled` 保持 1
#### Scenario: 列表响应打码
- **WHEN** admin GET 列出下游 key
- **THEN** 该行字段 `key` 形如 `••••••••<后4位>`,并附 `system: true`
#### Scenario: 普通 key 不受影响
- **WHEN** admin 操作其他普通 user_id 非空的 key
- **THEN** DELETE / PATCH / list 行为不受系统 key 保护逻辑影响
### Requirement: settings 中的内置密钥不暴露给 admin 列表
如果存在 admin 接口列出 `settings` 表内容,该接口 SHALL 过滤所有以 `builtin_key.` 为前缀的 key;按 key 单独读取时,对该前缀返回 404 或带占位符 `"••••"`,不返回实际明文。
#### Scenario: 列表过滤前缀
- **WHEN** admin GET `/api/admin/settings`(若存在)
- **THEN** 响应中不出现任何 key 以 `builtin_key.` 开头的条目
#### Scenario: 单读返回占位
- **WHEN** admin GET `/api/admin/settings/builtin_key.comfyui_agent.secret`(若存在)
- **THEN** 返回 404 或 `value: "••••"`,不返回真实明文
### Requirement: 内置 key 用 user_id IS NULL 与普通 key 区分
内置下游 key 在 `downstream_api_keys` 表中 SHALL 满足 `user_id IS NULL`;任何普通用户创建的 key MUST 写入非空 `user_id`,确保 `name='comfyui-agent' AND user_id IS NULL` 的查询能稳定匹配仅一行。
#### Scenario: 普通用户不能创建同名 key 占用系统标识
- **WHEN** 普通用户调用创建 key 接口,name 设为 `comfyui-agent`
- **THEN** 该 key 仍写入非空 user_id;系统级查询不会返回该行
#### Scenario: 引导查询仅匹配系统行
- **WHEN** 数据库中同时存在 user_id=5 且 name='comfyui-agent' 的普通 key 与 user_id IS NULL 的系统 key
- **THEN** `ensure_comfyui_agent_key` 仅作用于 user_id IS NULL 的那一行
@@ -1,81 +0,0 @@
## ADDED Requirements
### Requirement: ComfyUI Agent 默认调用本进程 /v1
ComfyUI Agent 的三类上游调用(`/v1/chat/completions`、`/v1/images/generations`、`/v1/audio/speech`)SHALL 默认指向本进程自身,base 由 `state.config.server.host` 与 `state.config.server.port` 拼出(host 为 `0.0.0.0` 或 `::` 时改写为 `127.0.0.1`)。Agent MUST NOT 在默认配置下访问任何外部主机。
#### Scenario: 默认 base 指向本机回环
- **WHEN** `BOOSAPI_UPSTREAM_BASE` 未设置且 `server.port = 4000`、`server.host = 0.0.0.0`
- **THEN** chat / images / audio 三类调用的 base 解析为 `http://127.0.0.1:4000/v1`
#### Scenario: 显式 host 被保留
- **WHEN** `server.host = 10.0.0.5`、`server.port = 4000` 且未设置 env
- **THEN** base 解析为 `http://10.0.0.5:4000/v1`,不被改写为回环
#### Scenario: 三类调用使用同一 base
- **WHEN** Agent 在同一次会话中触发 chat、images、audio 三类调用
- **THEN** 三次请求的 base 完全一致(来自同一 `upstream_config` 调用结果)
### Requirement: env 显式设置时回退远端
当且仅当 `BOOSAPI_UPSTREAM_BASE` 设置为非空字符串时,Agent SHALL 改用该 env 值作为 base,并使用 `BOOSAPI_UPSTREAM_KEY`(若设置且非空)或内置 key 的明文作为 Bearer。该分支以下统称 `fallback-remote` 模式。
#### Scenario: env 非空触发回退
- **WHEN** `BOOSAPI_UPSTREAM_BASE = "https://api.boos.lat/v1"`
- **THEN** 三类调用的 base 解析为 `https://api.boos.lat/v1`,且 `mode = "fallback-remote"`
#### Scenario: env 为空字符串视同未设置
- **WHEN** `BOOSAPI_UPSTREAM_BASE = ""` 或 `BOOSAPI_UPSTREAM_BASE = " "`
- **THEN** base 解析回本机回环,`mode = "local"`
#### Scenario: env key 缺省时复用内置 key
- **WHEN** 设置了 `BOOSAPI_UPSTREAM_BASE` 但未设置 `BOOSAPI_UPSTREAM_KEY`
- **THEN** Bearer 使用 `comfyui-agent` 内置 key 的明文,不退化为 `proxy_token`
### Requirement: 本地调用使用内置下游 key 计费
`mode = "local"` 时,Agent SHALL 在请求头 `Authorization` 中携带 `Bearer <comfyui-agent 内置 key 明文>`。该 key MUST 与本进程 `downstream_api_keys` 表中 `name='comfyui-agent' AND user_id IS NULL` 的行一致,使所有 Agent 流量在 `usage_logs` 中以该 key 归集。
#### Scenario: chat 请求携带内置 key
- **WHEN** Agent 在本地模式下发起 `/v1/chat/completions`
- **THEN** 请求头 `Authorization` 形如 `Bearer sk-comfyui-...`,且该值等于 settings 表中 `builtin_key.comfyui_agent.secret` 的值
#### Scenario: usage_logs 归集到内置 key
- **WHEN** 一次本地模式下的 chat 请求成功
- **THEN** `usage_logs` 中存在一条记录,`downstream_key_id` 等于内置 key 在 `downstream_api_keys` 中的 id
### Requirement: status 端点暴露生效模式
`GET /api/comfyui-agent/status` 响应 SHALL 在原有字段基础上增加 `mode: "local" | "fallback-remote"` 与 `keyName: "comfyui-agent"` 两个字段;`upstreamBase` 字段 MUST 反映当前实际生效的 base(已 trim 末尾斜杠)。
#### Scenario: 默认配置返回 local
- **WHEN** 客户端在未设置 env 的情况下 GET `/api/comfyui-agent/status`
- **THEN** 响应包含 `"mode": "local"`、`"keyName": "comfyui-agent"`、`upstreamBase` 形如 `http://127.0.0.1:<port>/v1`
#### Scenario: env 设置时返回 fallback-remote
- **WHEN** `BOOSAPI_UPSTREAM_BASE = "https://api.boos.lat/v1"` 时 GET 该端点
- **THEN** 响应 `"mode": "fallback-remote"`、`upstreamBase = "https://api.boos.lat/v1"`
#### Scenario: 鉴权失败仍不暴露密钥
- **WHEN** 客户端用错误的 admin token 请求
- **THEN** 返回 401,且响应体不包含 `keyName` / `upstreamBase` 任一字段
### Requirement: 本地路由失败必须如实返回
当本地 `/v1` 没有配置对应模型路由(chat 的 `agent_model`、images 的 `gpt-image-2`、audio 的 `tts-1`),Agent SHALL 把上游返回的错误状态码 / 文案如实通过 SSE `error` 事件转发给前端,MUST NOT 在收到 4xx 时自动切到 `fallback-remote`。
#### Scenario: 本地缺少 agent_model 路由
- **WHEN** 本地模式下发起 chat,token_router 返回 404
- **THEN** SSE 流中出现 `error` 事件,message 包含上游 404 内容;后续请求仍走本地,不自动切远端
@@ -1,85 +0,0 @@
## 1. 内置 key 引导逻辑
- [x] 1.1 新建 `src/services/builtin_keys.rs`,定义 `pub struct BuiltinKeys { pub comfyui_agent: (i64, String) }` 与 `pub async fn ensure_comfyui_agent_key(db: &SqlitePool) -> AppResult<(i64, String)>`
- [x] 1.2 实现 `ensure_comfyui_agent_key`:在事务中执行查 settings → 查 downstream_api_keys → 缺什么补什么,确保两边一致;密钥格式 `sk-comfyui-{uuid_v4 去横线}`,settings key = `builtin_key.comfyui_agent.secret`
- [x] 1.3 引导逻辑发现 `enabled=0` 时主动设回 1,并 `tracing::warn!("comfyui-agent builtin key was disabled; re-enabling")`
- [x] 1.4 在 `src/services/mod.rs` 中 `pub mod builtin_keys;`
- [x] 1.5 单测:用 `tempfile::NamedTempFile` + 内存 sqlite 覆盖 5 个场景(首次启动、settings 有表无、表有 settings 无、重复启动密钥不变、enabled=0 自动恢复)
## 2. AppState 接入
- [x] 2.1 在 `src/lib.rs` 中给 `AppState` 增加 `pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>`
- [x] 2.2 修改 `AppState::new` 签名为 async,或在 `main.rs` 中改为先 `ensure_comfyui_agent_key` 拿到结果再调 `AppState::new(config, pool, builtin_keys)`
- [x] 2.3 调整 `src/main.rs` 启动序列:migrate 之后、`AppState::new` 之前调用引导;任何 builtin key 相关错误用 `anyhow::Context` 包裹后向上传播,不让服务在没有 builtin key 时启动
- [x] 2.4 跑 `cargo build`、`cargo test --lib services::builtin_keys`,确认无回归
## 3. comfyui_agent.rs 切换调用目标
- [x] 3.1 在 `src/routes/comfyui_agent.rs` 顶部加 `enum AgentMode { Local, FallbackRemote }`,派生 Serialize 以便嵌入 JSON
- [x] 3.2 重写 `upstream_config(state) -> (String, String, AgentMode)`:env 非空(trim 后)走 fallback-remote 分支并把 `BOOSAPI_UPSTREAM_KEY` 缺省时用 builtin key;否则用 `state.config.server.host/port` 拼 `http://{host}/v1`,host 是 `0.0.0.0` 或 `::` 时改写 `127.0.0.1`
- [x] 3.3 三个 handler(chat 第 351 行、images 第 274 行、audio 第 311 行)全部改为接受 `(api_base, api_key, _mode)`,bearer 用新 `api_key`
- [x] 3.4 `status_handler` 响应增加 `"mode": <local|fallback-remote>`、`"keyName": "comfyui-agent"`,`upstreamBase` 仍为已 trim 的字符串
- [x] 3.5 单测(用 `axum-test`):env 未设、env 设、env 空字符串三种条件下 `/api/comfyui-agent/status` 返回的 mode 与 upstreamBase 正确
- [x] 3.6 集成测:`wiremock` mock 一个 `/v1/chat/completions` 返回固定 SSE,断言 Agent 在 local 模式下命中该 mock
## 4. admin 层保护内置 key
- [x] 4.1 用 `rg "FROM downstream_api_keys" /root/metapi/src/routes` 找到 admin list / get / patch / delete 入口
- [x] 4.2 在 list 响应映射处过滤:若 `name='comfyui-agent' AND user_id IS NULL` 则附加 `system: true` 字段、把 `key` 改为 `••••••••<后4位>`
- [x] 4.3 在 DELETE 入口加守卫:命中系统 key 返回 403 + message `system key cannot be deleted`
- [x] 4.4 在 PATCH/PUT 入口加守卫:当请求体试图把系统 key 的 `enabled` 设为 false 时拒绝
- [x] 4.5 路由级单测:3 条用例覆盖打码 / DELETE 拒绝 / 禁用拒绝;普通 key 操作不受影响 1 条用例
## 5. settings 暴露面过滤
- [x] 5.1 用 `rg "FROM settings"` 与 `rg "/api/admin/settings"` 找到 settings list / get 入口(如有)
- [x] 5.2 list 入口过滤所有以 `builtin_key.` 为前缀的 key
- [x] 5.3 单 key 读入口:以 `builtin_key.` 开头时返回 404 或 `value: "••••"`,不返回真实明文
- [x] 5.4 若上述路由不存在,把当前调研结果记入 commit message,跳过本组任务但保留 spec scenarios 待将来实现兜底
> 调研结论:当前 `src/routes/admin/mod.rs` 仅暴露 `GET /api/settings` (list-all) 与 `PUT /api/settings` (upsert),没有单 key GET。
> 已实现:
> - `get_settings` 在 `BTreeMap` 收敛前过滤 `key.starts_with("builtin_key.")`
> - `update_setting` 对 `builtin_key.*` 写入返回 403 `system setting cannot be modified`,作为防御性兜底(即便 UI 永远看不到该 key,也防止已知 key 名被直接 PUT 篡改)
> - 单 key GET 路由不存在,无需兜底;若将来新增需保留同样的 prefix 守卫
## 6. 文档与冒烟
- [x] 6.1 更新 `AGENTS.md` 第 32-36 行的 ComfyUI Agent 段落:默认本地、env 回退语义、密钥位置(settings + downstream_api_keys)
- [x] 6.2 更新 `README.md` 中相关段落(搜索"ComfyUI Agent"出现处)
- [ ] 6.3 跑全量校验:`cargo test`、`cargo clippy --all-targets`
- [ ] 6.4 手测:默认启动一次 chat / images / audio 各一次;设置 `BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1` 再启动一次三类调用;两次 status 响应 mode 字段切换正确(用户手测,等待执行)
- [x] 6.5 在 PR 描述中列出每条 spec scenario 的对应测试位置,便于 reviewer 校对(mapping 见下文「附:scenario → test 映射」)
## 附:scenario → test 映射
### specs/builtin-downstream-key/spec.md
- 启动时确保内置 key 存在
- 全新数据库首次启动 → `src/services/builtin_keys.rs` tests::ensures_key_on_first_boot
- 已有 settings 缺表记录(崩溃恢复) → `src/services/builtin_keys.rs` tests::backfills_table_from_settings
- 已有表记录缺 settings → `src/services/builtin_keys.rs` tests::backfills_settings_from_table
- 重复启动不变更密钥 → `src/services/builtin_keys.rs` tests::idempotent_across_two_boots
- enabled=0 自动恢复 → `src/services/builtin_keys.rs` tests::recovers_disabled_row
- 系统级 key 在管理 API 中受保护
- DELETE 被拒 / 禁用被拒 / 列表打码 / 普通 key 不受影响 → `src/routes/admin/downstream_keys.rs` `#[cfg(test)] mod tests`
- settings 中的内置密钥不暴露给 admin 列表
- 列表过滤前缀 → `src/routes/admin/mod.rs` tests::settings_list_filters_builtin_keys
- 单读返回占位 → 单 key GET 路由不存在;防御性写入守卫由 tests::settings_update_rejects_builtin_key 覆盖(详见 5.4 调研结论)
- 内置 key 用 user_id IS NULL 与普通 key 区分
- 普通用户不能创建同名 key 占用系统标识 / 引导查询仅匹配系统行 → builtin_keys.rs 引导逻辑使用 `name='comfyui-agent' AND user_id IS NULL` 限定 + admin tests 覆盖
### specs/comfyui-agent-local-routing/spec.md
- ComfyUI Agent 默认调用本进程 /v1
- 默认 base 指向本机回环 / 显式 host 被保留 / 三类调用使用同一 base → `src/routes/comfyui_agent.rs` tests::status_reflects_env_state_across_three_scenarios(Scenario A 覆盖回环改写、Scenario B 覆盖显式 host、共享 upstream_config 即覆盖三类一致)
- env 显式设置时回退远端
- env 非空触发回退 / env 空字符串视同未设置 → tests::status_reflects_env_state_across_three_scenarios(Scenario B + C)
- env key 缺省时复用内置 key → upstream_config 实现内联(见 routes/comfyui_agent.rs 的 fallback-remote 分支)
- 本地调用使用内置下游 key 计费
- chat 请求携带内置 key → tests::agent_local_mode_routes_chat_to_local_upstream(wiremock 断言 `Bearer sk-comfyui-test`)
- usage_logs 归集 → 间接覆盖(agent 经本进程 /v1,途经 token_router 自然写入 usage_logs,由现有 routes/proxy 路径覆盖)
- status 端点暴露生效模式
- 默认配置返回 local / env 设置时返回 fallback-remote → tests::status_reflects_env_state_across_three_scenarios
- 鉴权失败仍不暴露密钥 → 由 status_handler 鉴权中间件覆盖(无新增分支)
- 本地路由失败必须如实返回 → 由 SSE error 事件实现保留原行为,未新增专项测;如后续要硬覆盖,建议加 wiremock 404 + 断言 SSE error 事件
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-20
@@ -1,102 +0,0 @@
## Context
Today the Rust backend (`bosapi v1.3.0`) spawns a single embedded ComfyUI subprocess at boot via `services/comfyui_process.rs::ComfyUIProcess::new(...)` and stores it in `AppState.comfyui: Arc<ComfyUIProcess>`. All `/api/video-agent/*` handlers and any future `/comfy/*` traffic share this one process — so workflows, queue items, custom node state, and uploaded media leak between users. We need to support 100+ users with full per-user isolation, but we have no GPU (we use the BoosAPI text/image/TTS/video endpoints exclusively), so per-user instances are cheap (~1–2 GB resident with the API-only profile).
The user already runs ComfyUI in Docker as image `bosapi-comfyui:ready` and has accepted the "立刻可用 + 完全隔离" path: per-user on-demand container, with K3S as the v2 target. They added two late constraints we must honor:
1. Cap each container at **2 CPU / 2 GB RAM** (`--cpus=2 --memory=2g --memory-swap=2g`).
2. The existing `/api/video-agent/*` route surface is the in-app ComfyUI consumer; it must keep working for the same frontend with no contract change.
Auth is the existing JWT cookie. Users are identified by `user_id` from the cookie's claims. There is no per-org limit yet — only a single global cap.
## Goals / Non-Goals
**Goals:**
- Per-user ComfyUI isolation: workflows, queue, history, and uploaded files MUST NOT cross users.
- Lazy startup: a container exists only when the user is actively working; reaped after **60 minutes** idle.
- Hard ceiling: at most **30 concurrent containers**, hard-capped to bound memory at ~60 GB.
- Per-container resource cap: **2 CPU / 2 GB RAM** (memory + swap).
- Persistent custom workflows and outputs at `/data/comfyui-users/<user_id>/`.
- Reuse the existing `/api/video-agent/*` route surface unchanged from the frontend's perspective.
- New `/comfy/*` HTTP+WebSocket reverse proxy with cookie auth (no username in URL).
- Be K3S-portable: the container backend lives behind a `ContainerRuntime` trait so v2 can swap Docker for K3S with no caller changes.
- Built-in ComfyUI OpenAI nodes work via the `BOOSAPI_BUILTIN_KEY` env-var fallback (already patched in `_helpers.py`; bake into image).
**Non-Goals:**
- GPU support, local model weights, training. We are API-only.
- Per-organization quotas or per-user concurrency budgets — single global cap of 30 only.
- K3S backend implementation. v1 ships Docker only; the trait is the only K3S investment.
- Migrating away from `bosapi_comfy_nodes.py`. It coexists with the patched internal nodes.
- Replacing the existing dev-time `docker-compose.comfyui.yml`. Production stops using it; dev keeps it.
## Decisions
### 1. Per-user on-demand Docker container (vs shared+key-injection or per-node-key)
**Chosen:** spawn `bosapi-comfyui:multi-tenant` once per active user via `docker run`. **Rationale:** complete filesystem and queue isolation; no need to rewrite ComfyUI's WS protocol to mux users; the user's own framing was "立刻可用 + 完全隔离 ... 后期用K3S". **Alternatives considered:** (a) shared instance with per-request key injection — leaks queue/history across users, requires a reverse-engineered WS proxy that rewrites `client_id`, fragile; (b) per-node API-key — loses access to ComfyUI's built-in OpenAI nodes and prebuilt templates the user explicitly wants kept ("内置的节点和模板都很好用").
### 2. Pool sizing: 30 hard cap, 60-minute idle reap
**Chosen:** `MAX_CONCURRENT=30`, `IDLE_TIMEOUT=60min`. **Rationale:** 30 × 2 GB = 60 GB resident, fits a single mid-size box; 60 min covers a typical creative session without forcing repeated cold starts. Beyond 30, the pool returns `503` with `Retry-After`. **Alternative considered:** unbounded with LRU evict — but evicting a busy queue mid-render is worse UX than asking the next user to wait.
### 3. URL routing: `/comfy/*` at host root, identity from JWT cookie
**Chosen:** mount a reverse-proxy router at `/comfy/*` that reads the JWT cookie, resolves the user's container, and proxies HTTP+WS to `127.0.0.1:<port>`. **Rationale:** ComfyUI's HTML/JS asset paths assume root-relative URLs and break under subpath rewriting; putting the user_id in the URL path leaks it and complicates sharing/auth. **Alternative considered:** subdomain-per-user (`<user>.comfy.boos.lat`) — needs wildcard TLS and DNS, overkill for v1.
### 4. Container backend behind `ContainerRuntime` trait
**Chosen:**
```rust
#[async_trait]
pub trait ContainerRuntime: Send + Sync {
async fn start(&self, spec: ContainerSpec) -> Result<ContainerHandle>;
async fn stop(&self, id: &str) -> Result<()>;
async fn is_alive(&self, id: &str) -> Result<bool>;
}
```
v1 ships `DockerRuntime` (shells out to `docker` or talks to `/var/run/docker.sock`). v2 adds `K3sRuntime`. **Rationale:** the user has already picked K3S as v2; constraining now costs little and saves a rewrite. **Alternative considered:** hard-code `bollard` calls into the pool — couples lifecycle to Docker, rejected.
### 5. Resource limits per container
**Chosen:** `--cpus=2 --memory=2g --memory-swap=2g`, plus `--pids-limit=512`, `--read-only` rootfs with `--tmpfs /tmp:size=512m`, `--cap-drop=ALL`, `--security-opt=no-new-privileges`. Volume mounts: `/data/comfyui-users/<user_id>/input`, `/output`, `/temp`, `/user`, `/custom_nodes_user`. **Rationale:** user explicitly said "Docker的CPU RAM都要做限制 给个2C2G". The hardening flags follow defense-in-depth; ComfyUI does not need privileges or rootfs writes beyond the mounted volumes.
### 6. Idle reaper as a background tokio task
**Chosen:** a single tokio task ticks every 60 s, scans `pool.containers`, and stops any whose `last_used_at` is > 60 min ago. State recorded in-memory; a crash drops it but containers self-stop on `--rm` exit anyway (we do `docker stop`, not `docker kill`, so ComfyUI flushes). **Alternative considered:** Docker's own `--stop-timeout` + cron — split control plane between Rust and shell, harder to reason about.
### 7. Refactor of `/api/video-agent/*` (preserve frontend contract)
**Chosen:** every handler in `routes/video_agent.rs` extracts `user_id` from the JWT cookie, calls `state.comfyui_pool.get_or_start(user_id).await?`, and forwards to that handle's `start/stop/restart/status` or HTTP client. Status responses gain `containerState: "starting" | "ready" | "stopped"` so the frontend can render the new full-screen loading overlay. The existing JSON keys remain. **Alternative considered:** introduce a v2 namespace `/api/v2/video-agent/*` — gratuitous churn on the frontend.
### 8. AppState rename
**Chosen:** `pub comfyui_pool: Arc<ComfyUIPool>` replaces `pub comfyui: Arc<ComfyUIProcess>`. The old `ComfyUIProcess` type is **kept** — it's now the implementation detail used inside `DockerRuntime` to model the in-container ComfyUI subprocess (start/stop, port readiness probe, log buffer). `lib.rs` no longer constructs it directly; `DockerRuntime::start` does.
### 9. Image build
**Chosen:** new `docker/Dockerfile.comfyui` based on the user's existing `bosapi-comfyui:ready` lineage. Adds:
- the patched `comfy_source/comfy_api_nodes/util/_helpers.py` (env-var fallback)
- `bosapi_comfy_nodes.py` baked in (no host bind-mount, so it survives `--read-only`)
- `ENTRYPOINT` reads `BOOSAPI_BUILTIN_KEY`, `BOOSAPI_BASE`, `BOOSAPI_KEY`, then exec's `python main.py --listen 0.0.0.0 --port 8188 --comfy-api-base "$BOOSAPI_BASE/api/comfyui-agent"`.
Tag: `bosapi-comfyui:multi-tenant`. `build-and-run.sh` builds this image; the runtime spawns containers from it on demand.
## Risks / Trade-offs
- **Cold-start latency 10–20 s** → mitigated by full-screen loading UI with progress text; pool exposes `containerState` so the frontend can poll.
- **30-container ceiling can reject users at peak** → 503 with `Retry-After: 60`; UI shows a friendly "服务繁忙" page. Monitor and raise the cap (or move to K3S) if it bites.
- **Docker socket exposure** if the API runs in Docker → mount `/var/run/docker.sock` only, restrict to root in API container, never expose to user containers. Document for ops.
- **Port pool exhaustion (8200–8299 = 100 slots, cap is 30)** → 70-port headroom is fine; allocator picks lowest-free; on container stop the port is released.
- **Idle reap mid-render** → reap only when no active WS connections AND `last_used_at` > 60 min; bump `last_used_at` on every HTTP+WS frame. Acceptable false-negative rate.
- **Persistent volume disk growth** → no quota in v1. Document; add a per-user disk cap (e.g. 5 GB via `du`+reject) only if it becomes a real problem.
- **Built-in OpenAI nodes auth** depends on `BOOSAPI_BUILTIN_KEY` env reaching the container → enforce via `ContainerSpec.env` in the pool, not opt-in.
- **K3S abstraction tax** → only the trait shape; we still have to design pod specs later. Acceptable.
- **State loss on API restart** → containers keep running but the pool's in-memory map is gone. Mitigation: on startup, list `comfyui-user-*` containers, reconcile map. Tracked as a task.
## Migration Plan
1. Land `ContainerRuntime` trait + `DockerRuntime` + `ComfyUIPool` behind a feature flag `multi_tenant_comfy` (default off). The old `state.comfyui` singleton stays intact.
2. Build `bosapi-comfyui:multi-tenant` image; smoke-test by hand with `docker run`.
3. Add `/comfy/*` proxy and a single new `state.comfyui_pool` field alongside `state.comfyui`.
4. Refactor `/api/video-agent/*` handlers to read from `state.comfyui_pool` when the flag is on, else fall back to `state.comfyui`.
5. Toggle the flag on in staging; verify with two real users that workflows/uploads do not cross.
6. Remove `state.comfyui` and the flag. `docker-compose.comfyui.yml` becomes dev-only.
7. Document host requirements in `README` (Docker socket, `/data/comfyui-users/`, image build).
**Rollback:** flip the feature flag off and redeploy. Containers from the multi-tenant path keep running until idle-reaped; no data loss because volumes are persistent.
## Open Questions
- Should the API container run with `docker:dind` or mount the host socket? Default to host socket; revisit if ops needs isolation.
- Where do we publish the loading-state UI string set? Put it next to other i18n strings in the frontend repo.
- Do we expose `/comfy/admin/*` for ops to introspect the pool? Out of scope for v1; add later if needed.
@@ -1,43 +0,0 @@
## Why
Today every user of `/api/video-agent/*` and `/comfy/*` shares one global `Arc<ComfyUIProcess>` singleton spawned at boot. That means workflows, queue, custom nodes, and uploaded assets are visible across users — unsuitable for the 100+ user multi-tenant deployment we are moving toward. We need per-user isolation now, with an architecture that can move to K3S later without a second rewrite.
## What Changes
- **BREAKING (internal API only):** Replace the `state.comfyui: Arc<ComfyUIProcess>` singleton with `state.comfyui_pool: Arc<ComfyUIPool>`. All existing `/api/video-agent/*` handlers refactor to look up (or lazily start) the caller's container via the pool.
- Introduce per-user on-demand ComfyUI containers. One container per authenticated user, started on first `/comfy/*` or `/api/video-agent/*` request, reaped after **60 minutes idle**.
- Cap the pool at **30 concurrent containers** (≈60 GB RAM at 2 GB each). Requests beyond that queue with a clear "service busy" response and full-screen loading UI hint.
- Each container limited to **2 CPU / 2 GB RAM** (`--cpus=2 --memory=2g --memory-swap=2g`), bound to a port from the **8200–8299** pool, named `comfyui-user-<user_id>`.
- Persistent per-user volume mounted at `/data/comfyui-users/<user_id>/` so custom workflows, inputs, and outputs survive container restarts.
- Add a reverse-proxy route family `/comfy/*` (HTTP + WebSocket) that routes the authenticated user to their own container. **No username in URL** — identity comes from the existing JWT cookie. ComfyUI's HTML/asset paths assume root, so we proxy at the host level rather than under a subpath.
- Bake the existing `comfy_source/comfy_api_nodes/util/_helpers.py` env-var fallback patch into the container image so ComfyUI's built-in OpenAI nodes pick up `BOOSAPI_BUILTIN_KEY` automatically.
- Abstract the container backend behind a `ContainerRuntime` trait. v1 ships `DockerRuntime`. v2 (when we move to K3S) adds `K3sRuntime` without touching pool, proxy, or route code.
- Wire pool startup, idle reaper, and proxy into the existing axum app and `build-and-run.sh`.
## Capabilities
### New Capabilities
- `comfyui-multi-tenant`: per-user ComfyUI container lifecycle (start, idle reap, max-concurrency, port allocation, persistent volumes).
- `container-runtime`: backend-agnostic container abstraction (`ContainerRuntime` trait + `DockerRuntime` impl) so K3S can be added later without rewriting callers.
- `comfyui-proxy`: cookie-authenticated reverse proxy at `/comfy/*` (HTTP + WebSocket) that maps the request to the caller's container.
- `video-agent-per-user`: refactor of the existing `/api/video-agent/*` handlers to use the per-user pool instead of the global singleton, preserving the current frontend contract.
### Modified Capabilities
<!-- None — no specs/ directory exists yet, so all four capabilities are net-new. -->
## Impact
- **Code touched (Rust):**
- `src/services/comfyui_process.rs` — keep as the in-container process model used by `DockerRuntime`, no longer spawned directly by `lib.rs`.
- `src/services/` — new `comfyui_pool.rs`, `container_runtime.rs`, `docker_runtime.rs`.
- `src/routes/video_agent.rs` — every handler switches from `state.comfyui` to `state.comfyui_pool.get_or_start(user_id)`.
- `src/routes/` — new `comfy_proxy.rs` mounting `/comfy/*` (HTTP + WS).
- `src/lib.rs` — `AppState` field rename, pool construction, idle-reaper task spawn.
- **Infra:**
- New `docker/Dockerfile.comfyui` baking the `_helpers.py` patch and `bosapi_comfy_nodes.py` into `bosapi-comfyui:multi-tenant`.
- `build-and-run.sh` builds the image; the Rust binary itself launches/stops containers at runtime (no docker-compose).
- Host paths: `/data/comfyui-users/<user_id>/` (persistent), `/var/run/docker.sock` mounted into the API container if API runs in Docker.
- **Auth:** Existing JWT cookie used as-is. New `/comfy/*` proxy reads it; existing `/api/video-agent/*` already does.
- **Frontend:** No contract change for `/api/video-agent/*`. New full-screen loading UI shown during the 10–20 s container startup; backend exposes a status endpoint the UI can poll.
- **Removals:** Old `docker-compose.comfyui.yml` becomes dev-only; production no longer uses it. The single global `state.comfyui` field disappears.
- **Out of scope:** GPU support (we are API-only), per-org quotas (single global cap of 30 only), K3S backend implementation (v2 task).
@@ -1,78 +0,0 @@
## ADDED Requirements
### Requirement: Per-user container lifecycle
The system SHALL maintain at most one ComfyUI container per authenticated user. The container SHALL be started lazily on the user's first request to `/comfy/*` or `/api/video-agent/*` and SHALL be reaped after 60 minutes of inactivity.
#### Scenario: First request starts the container
- **WHEN** an authenticated user with no running container makes a request to `/comfy/*`
- **THEN** the system starts a container named `comfyui-user-<user_id>`, allocates a port from 8200–8299, records `last_used_at = now`, and forwards the request once the container's HTTP port is reachable
#### Scenario: Subsequent request reuses the container
- **WHEN** the same user makes another request within 60 minutes
- **THEN** the system reuses the existing container, updates `last_used_at = now`, and forwards the request without restarting
#### Scenario: Idle container is reaped
- **WHEN** a container's `last_used_at` is older than 60 minutes AND it has no active WebSocket connections
- **THEN** the system stops the container via the runtime's `stop` method and releases its port back to the pool
### Requirement: Concurrency cap
The system SHALL refuse to start a new container when 30 are already running.
#### Scenario: 31st user is rejected
- **WHEN** 30 containers are running and a 31st distinct user requests `/comfy/*`
- **THEN** the system responds with HTTP 503 and a `Retry-After: 60` header, and does NOT evict any existing container
#### Scenario: Cap frees on idle reap
- **WHEN** any of the 30 containers is reaped
- **THEN** the next pending user request that arrives starts a fresh container in the freed slot
### Requirement: Resource limits
Each container SHALL run with `--cpus=2`, `--memory=2g`, `--memory-swap=2g`, `--pids-limit=512`, `--read-only` rootfs, `--cap-drop=ALL`, and `--security-opt=no-new-privileges`.
#### Scenario: Container respects CPU and memory caps
- **WHEN** a new container is started
- **THEN** `docker inspect` shows `HostConfig.NanoCpus = 2000000000`, `HostConfig.Memory = 2147483648`, `HostConfig.MemorySwap = 2147483648`
#### Scenario: Container has no extra capabilities
- **WHEN** a new container is started
- **THEN** `docker inspect` shows `HostConfig.CapDrop` contains `ALL` and `HostConfig.SecurityOpt` contains `no-new-privileges`
### Requirement: Persistent per-user volumes
Each container SHALL mount the host directory `/data/comfyui-users/<user_id>/` so that custom workflows, inputs, and outputs survive container restarts.
#### Scenario: Workflow saved in one session is visible after restart
- **WHEN** a user saves a workflow file to ComfyUI's `user/` directory, then triggers a container restart (idle reap + new request)
- **THEN** the workflow file is still present in the new container's `user/` directory
#### Scenario: Volume is per-user
- **WHEN** user A saves a file and user B starts their own container
- **THEN** user B's container does NOT see user A's file
### Requirement: Built-in API key injection
Each container SHALL receive `BOOSAPI_BUILTIN_KEY`, `BOOSAPI_BASE`, and `BOOSAPI_KEY` as environment variables so ComfyUI's built-in OpenAI nodes (via the patched `_helpers.py`) and the `bosapi_comfy_nodes.py` custom nodes both authenticate successfully.
#### Scenario: Internal OpenAI node authenticates
- **WHEN** a workflow uses a built-in ComfyUI OpenAI node and the container has `BOOSAPI_BUILTIN_KEY` set
- **THEN** the node's outbound request to the BoosAPI shim includes `Authorization: Bearer <BOOSAPI_BUILTIN_KEY>`
#### Scenario: Custom BoosAPI node authenticates
- **WHEN** a workflow uses `BoosAPITextGen` from `bosapi_comfy_nodes.py` and the container has `BOOSAPI_KEY` set
- **THEN** the node's request to `BOOSAPI_BASE` includes `Authorization: Bearer <BOOSAPI_KEY>`
### Requirement: Container state observable to callers
The pool SHALL expose each container's state as `starting`, `ready`, or `stopped` so the frontend can render a loading UI during cold start.
#### Scenario: Status reflects starting then ready
- **WHEN** a user's first request triggers a container start
- **THEN** `pool.state(user_id)` returns `starting` until the HTTP port is reachable, then `ready`
#### Scenario: Status reflects stopped after reap
- **WHEN** a container has been idle-reaped
- **THEN** `pool.state(user_id)` returns `stopped`
### Requirement: Reconcile on startup
On API restart, the pool SHALL list all `comfyui-user-*` containers from the runtime and reconstruct its in-memory map (user_id, port, last_used_at).
#### Scenario: Existing containers are adopted
- **WHEN** the API process restarts while a `comfyui-user-42` container is still running
- **THEN** the new pool's map contains an entry for user 42 with the original container's port, and the next request from user 42 reuses it
@@ -1,45 +0,0 @@
## ADDED Requirements
### Requirement: Cookie-authenticated reverse proxy at /comfy/*
The system SHALL mount a reverse proxy at `/comfy/*` that resolves the caller's `user_id` from the existing JWT cookie and forwards both HTTP and WebSocket traffic to that user's container.
#### Scenario: Anonymous request is rejected
- **WHEN** a request to `/comfy/` arrives without a valid JWT cookie
- **THEN** the proxy responds with HTTP 401 and does NOT touch the container pool
#### Scenario: Authenticated HTTP request reaches the right container
- **WHEN** user 7 requests `GET /comfy/api/object_info`
- **THEN** the proxy forwards the request body, query, and headers to `http://127.0.0.1:<port_for_user_7>/api/object_info` and streams the response back unchanged
#### Scenario: Authenticated WebSocket upgrade is proxied
- **WHEN** user 7's browser opens `wss://host/comfy/ws?clientId=abc`
- **THEN** the proxy upgrades the connection, opens a WS to `ws://127.0.0.1:<port_for_user_7>/ws?clientId=abc`, and pipes frames in both directions until either side closes
### Requirement: No URL rewriting of asset paths
The proxy SHALL forward request paths to the container with the `/comfy` prefix stripped and SHALL NOT rewrite response bodies.
#### Scenario: Asset path is preserved
- **WHEN** the browser requests `/comfy/extensions/core/foo.js`
- **THEN** the proxy forwards `/extensions/core/foo.js` to the container, and the response body is returned byte-for-byte
#### Scenario: ComfyUI's root-relative URLs work
- **WHEN** the container returns HTML referencing `/scripts/app.js`
- **THEN** the browser fetches `/comfy/scripts/app.js` (because the page was served under `/comfy/`) and the proxy resolves it correctly
### Requirement: Cold-start handling
If a request arrives while the user's container is `starting`, the proxy SHALL hold the request (up to 30 s) until the container reports `ready`, instead of returning 502.
#### Scenario: Request waits for cold start
- **WHEN** user 7's first request to `/comfy/` arrives and the container needs 12 s to start
- **THEN** the proxy waits for the readiness signal and forwards the request once the container is ready, returning a normal 200
#### Scenario: Cold-start timeout
- **WHEN** a container fails to become ready within 30 s
- **THEN** the proxy responds with HTTP 504 and the pool marks the container `failed` for the next idle scan
### Requirement: Last-used timestamp updated on every frame
Every HTTP request and WebSocket frame proxied to the container SHALL update `pool.containers[user_id].last_used_at = now`.
#### Scenario: Long-running WS keeps container alive
- **WHEN** a user holds a WS connection open for 90 minutes while exchanging frames every minute
- **THEN** the container is not reaped at the 60-minute mark
@@ -1,34 +0,0 @@
## ADDED Requirements
### Requirement: Backend-agnostic ContainerRuntime trait
The system SHALL expose container lifecycle behind a single `ContainerRuntime` trait so the pool, proxy, and route handlers do NOT depend on Docker directly.
#### Scenario: Pool uses only the trait
- **WHEN** the source for `services/comfyui_pool.rs` is inspected
- **THEN** it imports `ContainerRuntime` and `ContainerSpec` only — never `bollard`, `docker`, or `kube` types
#### Scenario: Trait surface is sufficient for v1
- **WHEN** a `DockerRuntime` is provided
- **THEN** the pool can start containers, stop them, check liveness, and list existing `comfyui-user-*` containers using only the trait's methods
### Requirement: DockerRuntime implementation
The system SHALL ship a `DockerRuntime` that implements `ContainerRuntime` against the local Docker daemon, either via the host socket `/var/run/docker.sock` or the `docker` CLI.
#### Scenario: Start spawns a container
- **WHEN** `DockerRuntime::start(spec)` is called with `image=bosapi-comfyui:multi-tenant`, `name=comfyui-user-7`, port mapping, env, and volume mounts
- **THEN** a container with that name exists, is running, and exposes the chosen host port
#### Scenario: Stop removes the container
- **WHEN** `DockerRuntime::stop("comfyui-user-7")` is called
- **THEN** the container is stopped and removed (image used `--rm`-equivalent semantics) and its name becomes reusable
#### Scenario: List finds existing user containers
- **WHEN** `DockerRuntime::list_user_containers()` is called
- **THEN** it returns one entry per running container whose name starts with `comfyui-user-`, including its port mapping
### Requirement: K3S extension point reserved
The trait SHALL be designed such that a future `K3sRuntime` implementing the same surface can replace `DockerRuntime` without changes to the pool, proxy, or route handlers.
#### Scenario: Swapping runtimes does not break callers
- **WHEN** `AppState::comfyui_pool` is constructed with a `Box<dyn ContainerRuntime>` instead of a concrete `DockerRuntime`
- **THEN** all callers compile and behavior is unchanged for the `DockerRuntime` instance
@@ -1,41 +0,0 @@
## ADDED Requirements
### Requirement: video-agent handlers route through the pool
Every handler under `routes/video_agent.rs` SHALL resolve the caller's `user_id` from the JWT cookie and operate on `state.comfyui_pool.get_or_start(user_id)` instead of the global `state.comfyui` singleton. The wire format of existing endpoints SHALL NOT change.
#### Scenario: status reflects per-user container
- **WHEN** user 7 calls `GET /api/video-agent/status`
- **THEN** the response describes user 7's container only, including a new `containerState: "starting" | "ready" | "stopped"` field, and the pre-existing JSON keys are preserved
#### Scenario: start is idempotent per user
- **WHEN** user 7 calls `POST /api/video-agent/start` twice in a row
- **THEN** the first call starts (or reuses) the container and returns 200, the second call returns 200 immediately without spawning a duplicate container
#### Scenario: stop only stops the caller's container
- **WHEN** user 7 calls `POST /api/video-agent/stop`
- **THEN** user 7's container is stopped and removed, and any other user's running container is unaffected
#### Scenario: chat / analyze / generate-* call the caller's container
- **WHEN** user 7 calls any of `/api/video-agent/{chat,analyze,character-prompts,generate-character,scene-prompt,generate-scene,generate-tts,full-workflow}`
- **THEN** the handler forwards the request to user 7's container's HTTP endpoint and returns the response
### Requirement: Backwards-compatible response shape
Existing JSON keys returned by `/api/video-agent/*` SHALL remain present and identical to the singleton-era response. Only additive fields are allowed.
#### Scenario: status payload is a superset
- **WHEN** the frontend (unchanged) parses the response of `/api/video-agent/status`
- **THEN** every field it currently reads is still present with the same type and meaning; only `containerState` (and any new optional fields) are added
### Requirement: Authentication required
All `/api/video-agent/*` routes SHALL require a valid JWT cookie. Anonymous requests SHALL return HTTP 401 without touching the pool.
#### Scenario: Anonymous request blocked
- **WHEN** an unauthenticated client calls `POST /api/video-agent/start`
- **THEN** the response is HTTP 401 and no container is started
### Requirement: Removal of global state.comfyui
The `comfyui: Arc<ComfyUIProcess>` field on `AppState` SHALL be removed once `comfyui_pool` is the sole code path. No call site SHALL retain a reference to the singleton.
#### Scenario: Codebase contains no singleton refs
- **WHEN** the source tree is grepped for `state.comfyui` (without the `_pool` suffix)
- **THEN** there are zero matches outside of historical comments
@@ -1,69 +0,0 @@
## 1. ContainerRuntime trait + DockerRuntime
- [x] 1.1 Add `services/container_runtime.rs` with `ContainerRuntime` trait, `ContainerSpec` (image, name, env, port_map, mounts, cpu, mem, mem_swap, pids_limit, read_only, cap_drop, security_opt), `ContainerHandle` (id, host_port).
- [x] 1.2 Add `services/docker_runtime.rs` implementing `ContainerRuntime` via `bollard` (or `tokio::process::Command` shelling to `docker` if `bollard` is heavy) — `start`, `stop`, `is_alive`, `list_user_containers`.
- [ ] 1.3 Unit-test `DockerRuntime` against the local Docker daemon: start/stop/list cycle on a hello-world image; assert resource flags via `inspect`. _(skipped this session — needs a live Docker daemon)_
## 2. ComfyUIPool
- [x] 2.1 Add `services/comfyui_pool.rs` with `ComfyUIPool { runtime: Arc<dyn ContainerRuntime>, containers: RwLock<HashMap<i64, PoolEntry>>, ports: PortAllocator(8200..=8299), max: usize, idle_timeout: Duration }`.
- [x] 2.2 Implement `get_or_start(user_id) -> Result<ContainerHandle>`: dedupe in-flight starts via per-user `Mutex<Option<oneshot::Receiver>>`; allocate port; build `ContainerSpec` with `image=bosapi-comfyui:multi-tenant`, name `comfyui-user-<id>`, env (`BOOSAPI_BUILTIN_KEY`, `BOOSAPI_BASE`, `BOOSAPI_KEY`), volumes for `/data/comfyui-users/<id>/{input,output,temp,user,custom_nodes_user}`, resource caps from §design Decision 5.
- [x] 2.3 Readiness probe: poll `GET http://127.0.0.1:<port>/system_stats` until 200 or 30 s timeout; mark state `ready`/`failed`.
- [x] 2.4 Implement `stop(user_id)`, `state(user_id) -> "starting"|"ready"|"stopped"|"failed"`, `touch(user_id)`.
- [x] 2.5 Enforce concurrency cap: when `containers.len() >= 30`, return `PoolError::AtCapacity`; map to HTTP 503 `Retry-After: 60` at the proxy/route layer.
- [x] 2.6 Spawn idle-reaper tokio task: every 60 s, `stop` containers whose `last_used_at` > 60 min AND have no active WS connections.
- [x] 2.7 Reconcile on startup: `runtime.list_user_containers()` → seed `containers` map with existing `comfyui-user-*` entries.
## 3. AppState wiring
- [x] 3.1 Add `comfyui_pool: Arc<ComfyUIPool>` to `AppState` in `src/lib.rs` (keep `comfyui` field temporarily behind `cfg(feature = "legacy_comfy_singleton")` for rollback).
- [x] 3.2 Construct the pool in `lib.rs::build_app_state` from `DockerRuntime::new()` and the existing `BuiltinKeys.comfyui_agent` secret.
- [x] 3.3 Spawn the idle-reaper task at startup; add a graceful-shutdown hook that `stop`s any container we started in this process.
## 4. /comfy/* reverse proxy
- [x] 4.1 Add `routes/comfy_proxy.rs` mounting `any("/comfy/*path")` and `get("/comfy/ws")` (WS upgrade) using `axum::extract::ws` + `reqwest` (HTTP) / `tokio_tungstenite` (WS-to-WS).
- [x] 4.2 Auth middleware: extract JWT cookie, resolve `user_id`; on missing/invalid, return 401 without touching the pool.
- [x] 4.3 HTTP forwarder: strip `/comfy` prefix; copy method, path, query, headers (drop hop-by-hop), body; stream response back unchanged.
- [x] 4.4 WebSocket forwarder: open server-side WS to `ws://127.0.0.1:<port>/ws?...`; pump frames bidirectionally; close on either-side close.
- [x] 4.5 Cold-start hold: if `pool.state(user_id)` is `starting`, await readiness up to 30 s before forwarding; on timeout return 504.
- [x] 4.6 Touch `pool` on every HTTP request and every inbound/outbound WS frame.
- [x] 4.7 Register the router in `lib.rs::build_router` so `/comfy/*` is reachable.
## 5. /api/video-agent/* refactor
- [x] 5.1 In `routes/video_agent.rs`, add a helper `resolve_user_id(headers) -> Result<i64, AppError>` reading the JWT cookie.
- [x] 5.2 Rewrite `status` to call `pool.state(user_id)` and (if ready) the container's HTTP endpoint; preserve every existing JSON key, add `containerState`.
- [x] 5.3 Rewrite `start_comfyui` to `pool.get_or_start(user_id)` (idempotent).
- [x] 5.4 Rewrite `stop_comfyui` and `restart_comfyui` to act on the caller's container only.
- [x] 5.5 Rewrite `chat`, `analyze`, `character_prompts`, `generate_character`, `scene_prompt`, `generate_scene`, `generate_tts`, `full_workflow` to forward to `http://127.0.0.1:<port>/...` for the caller's container.
- [x] 5.6 Anonymous-request guard: every handler returns 401 if `resolve_user_id` fails.
## 6. Image build
- [x] 6.1 Add `docker/Dockerfile.comfyui` based on user's existing `bosapi-comfyui:ready` lineage; copy patched `comfy_source/comfy_api_nodes/util/_helpers.py` and `bosapi_comfy_nodes.py` into `/workspace/ComfyUI/`. _(implemented as `docker/Dockerfile.comfyui.multi-tenant` to keep the legacy singleton image untouched until rollout 9.4)_
- [x] 6.2 ENTRYPOINT script reads `BOOSAPI_BUILTIN_KEY`/`BOOSAPI_BASE`/`BOOSAPI_KEY` and execs `python main.py --listen 0.0.0.0 --port 8188 --comfy-api-base "$BOOSAPI_BASE/api/comfyui-agent"`. _(`docker/comfyui-entrypoint.sh`)_
- [x] 6.3 Tag image as `bosapi-comfyui:multi-tenant`; update `build-and-run.sh` to build it before launching the API.
- [ ] 6.4 Smoke-test: `docker run --rm --cpus=2 --memory=2g -e BOOSAPI_BUILTIN_KEY=... -p 18188:8188 bosapi-comfyui:multi-tenant` and curl `/system_stats`.
## 7. Frontend integration
- [ ] 7.1 Update video-agent panel to render full-screen loading overlay when `containerState === "starting"`; poll `/api/video-agent/status` every 2 s.
- [ ] 7.2 Add a `/comfy` entry point (link or iframe) gated on the user being authenticated; show `containerState` while loading.
- [ ] 7.3 Handle 503 from `/comfy/*` and `/api/video-agent/*` by showing a "服务繁忙,请稍候" page and retrying after `Retry-After`.
## 8. Verification
- [x] 8.1 `cargo check --all-targets` green.
- [ ] 8.2 `cargo test` green for new pool / proxy / route tests; pre-existing failures from task #27 explicitly out of scope.
- [ ] 8.3 Manual two-user test on staging: user A uploads image into ComfyUI, user B starts a fresh container and confirms A's file is NOT visible.
- [ ] 8.4 Manual cold-start test: stop user A's container, hit `/comfy/`, confirm full-screen loader shows for ~10–20 s, then ComfyUI loads.
- [ ] 8.5 Manual cap test: start 30 containers, confirm 31st request returns 503 with `Retry-After`.
- [ ] 8.6 Idle-reap test: leave a container idle for 65 minutes, confirm `docker ps` no longer lists it and the next request cold-starts.
## 9. Rollout
- [x] 9.1 Land 1–6 behind `multi_tenant_comfy` feature flag (default off) so legacy singleton path keeps working.
- [ ] 9.2 Enable in staging; run §8 manual tests.
- [ ] 9.3 Enable in production; monitor pool metrics for 1 week.
- [ ] 9.4 Remove `state.comfyui` singleton field, the feature flag, and any compat branches; archive legacy `docker-compose.comfyui.yml` references.
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-19
@@ -1,81 +0,0 @@
## Context
`src/web/App.tsx` 当前 1135 行,其中第 132–500+ 行的 `Login` 组件混合了三种登录方式(管理员 token、管理员账号密码、普通用户账号密码)和品牌侧栏文案;`src/web/pages/UserLogin.tsx` 又是同一类登录的重复实现。错误文案分别在 `loginError.ts`(仅 token 登录)、`App.tsx` 内联(账号密码登录)、`UserLogin.tsx` 内联三处解析;后端 `routes/auth.rs` 同时挂在 `/api/auth/login` 与 `/api/users/login` 两个路径上输出同一 JSON 形状。
OAuth 现状:`src/routes/oauth.rs` 顶部注释明确 "Simplified implementation — complex OAuth flow operations return 501"。`start_provider_flow` / `get_session` / `manual_callback` 都直接返回 "not implemented",没有 session 存储数据结构(`rg "oauth_sessions"` 零命中)。把 OAuth 当登录入口需要先把整个 provider flow 从 stub 写到可用,与本次前端拆分不在同一量级,因此从本次范围拆出。
约束:保留现栈(React 18 + 自研 `authSession.ts`),不引入路由库(已在用 `react-router-dom`)外的状态库;不改造桌面端 deeplink。
## Goals / Non-Goals
**Goals:**
- 把 `App.tsx` 内的登录组件拆出,得到 `pages/auth/LoginShell` + `AdminLoginPage` + `UserLoginPage` 三个文件,单文件不超过 ~250 行。
- 所有登录入口(管理员 token、管理员/用户账号密码)的失败提示走同一个 `resolveLoginErrorMessage`,状态码 → 文案映射集中在一处。
- `authSession.ts` 暴露 `commitSession({ audience, token })` / `clearSession(audience)` / `getSessionToken(audience)` 入口,所有登录路径只调用它,旧 API 标 `@deprecated` 但保留薄壳。
- `UserLoginPage` 底部留 `<OAuthSlot />` 占位组件,本次只渲染容器不挂行为,为下一个 change 留出 UI 落点。
**Non-Goals:**
- 不实现任何 OAuth provider flow(前后端皆不动)。
- 不替换 React、不引入 zustand/jotai 或 react-hook-form。
- 不重写 `routes/oauth.rs`。
- 不动桌面端(electron)登录链路。
- 不做 MFA / 邮箱验证码 / 注册流程改造(注册页不在本次范围)。
## Decisions
### 1. 拆分粒度:抽 `LoginShell` + 两个 page 组件,而不是一个统一 Login
`LoginShell` 负责品牌侧栏、容器、i18n 注入、错误展示槽位;`AdminLoginPage` 持有 token/账密两种 mode 切换;`UserLoginPage` 只有账密一种 mode + OAuth 槽位(占位)。
**为何不做单一 `<Login audience>` 组件**:当前实现就是这种思路,却让 props 组合(audience × mode × OAuth)爆炸。两个 page 组件之间共享 30% 代码(表单原语 + 提交按钮),用 `LoginShell` + 表单子组件复用即可,不必塞回一个组件。
**为何不直接删 `pages/UserLogin.tsx`**:保留路由 `/user/login` 让用户书签可用,但内部薄壳化,挂 `UserLoginPage` 即可。
### 2. 登录错误统一:扩展 `loginError.ts`,对 axios-style 异常归一
现状 `resolveLoginErrorMessage(status, reason)` 只覆盖 401/403/5xx 三类。新增:
- 400:参数错误,区分"邮箱格式不合法"/"密码不能为空"两种 reason(后端已有 `bad_request` 文案)。
- 409:账号被锁定 / 多次失败(后端目前没有,留接口以便后续接入)。
- 网络错误(fetch reject):返回 `网络异常,请检查连接`。
`AdminLogin` 与 `UserLogin` 都通过 `await loginRequest(...)` 拿统一形状 `{ status, reason }`,再交给 `resolveLoginErrorMessage`。
**为何不引入新错误库**:错误形状只有 5 种分支,多一层抽象反而模糊。
### 3. 会话写入收敛到 `authSession.commitSession`
新签名:
```ts
type Audience = "admin" | "user";
function commitSession(audience: Audience, token: string, ttlMs?: number, storage?, now?): void;
function clearSession(audience: Audience, storage?): void;
function getSessionToken(audience: Audience, storage?, now?): string | null;
```
底层仍然用现有 `AUTH_TOKEN_*` / `USER_JWT_*` 两套 key,对外暴露统一 API。`persistAuthSession` / `persistUserSession` 标记为 `@deprecated`,本次先不删(避免影响其他 page 直接调用的地方),下一次清理。
### 4. OAuth 占位:组件级 `OAuthSlot`,目前只渲染容器
`UserLoginPage` 底部嵌一个 `<OAuthSlot variant="user" />`,组件内部本次只输出一个空 `<section>`(或带 i18n 文案的 placeholder),不调任何 OAuth API。后续 change 在同一组件里实现 provider 列表 + 跳转 + exchange 链路。
**为何不直接留空白**:留可识别的 DOM 节点能让后续 change 用 `data-testid="oauth-slot"` 精准插入;留 `OAuthSlot` 文件还能让 `UserLoginPage` 的 import 形态稳定,避免后续 change 又改 page 组件 import。
## Risks / Trade-offs
- **[Risk] 拆分过程中 i18n key 错位**:现有 `t('登录')` 等中文 key 和 `i18n.tsx` supplement 强耦合,移动文件时容易丢翻译。→ Mitigation:拆分时一次性把 `t('xxx')` 调用搬到新文件,并跑 `i18n.test.ts` 校验。
- **[Risk] 测试迁移漏掉断言**:`App.login-surface.test.tsx` 是基于 `App.tsx` 内的 DOM 断言,组件路径变了断言要重写。→ Mitigation:新建 `pages/auth/LoginShell.test.tsx` 把原断言搬过去,老测试文件保留薄壳验证 `App.tsx` 里挂载入口仍存在。
- **[Trade-off] 保留 `persistAuthSession` / `persistUserSession`**:增加一次过渡期的 API 重复,但避免本次 PR 触碰过多调用点。
- **[Trade-off] OAuth 占位组件本次不挂任何行为**:会有一个看起来空的 DOM 区域,但避免本次 PR 误把"占位"当"已接入"看待;文档段落明确说明。
## Migration Plan
1. **第 1 步(无破坏)**:扩展 `loginError.ts` + 新建 `authSession.commitSession`,老函数转为薄壳调用新函数。跑全量单测确认无回归。
2. **第 2 步(前端拆分)**:新建 `src/web/pages/auth/LoginShell.tsx` + `AdminLoginPage.tsx` + `UserLoginPage.tsx` + `OAuthSlot.tsx`(占位);`App.tsx` 改为挂 `AdminLoginPage`;`pages/UserLogin.tsx` 内部 import `UserLoginPage`。手测三种现有登录路径。
3. **第 3 步(清理)**:删除 `App.tsx` 中已无用的 Login 组件 dead code,更新 `App.login-surface.test.tsx`,更新文档。
回滚:每步独立提交;如果第 2 步出问题,revert 即可恢复 `App.tsx` 内联实现。
## Open Questions
- 桌面端(electron)登录是否也通过同一组件?本次明确不在范围,但 `LoginShell` 不能假设 `window.location.origin` 是 web,组件内不出现 `window.location` 直接访问。
- `OAuthSlot` 是否需要通过 feature flag 控制可见性?本次按"始终渲染容器、内部留 placeholder"处理,下一个 change 再决定。
@@ -1,34 +0,0 @@
## Why
`src/web/App.tsx` 已经膨胀到 1135 行,其中 `Login` 组件(约 132–500+ 行)耦合了管理员 token 登录、管理员账号密码登录、用户账号密码登录、品牌侧栏、能力位文案和 i18n 适配,与同文件里另一个用户专用的 `pages/UserLogin.tsx` 形成两套并行实现。错误提示分散在三处(`loginError.ts`、`App.tsx` 内联解析、`UserLogin.tsx` 内联解析),状态码 / 文案不一致。
继续在 `App.tsx` 内堆登录逻辑会让后续接入新的登录方式(OAuth 第三方登录、邮箱验证码等)越来越难,也会让"登录失败"这类用户能看到的错误持续不一致。
OAuth 第三方登录入口本来与本次重构同期推进,但实施前确认 `src/routes/oauth.rs` 全套 provider flow 仍是 stub(所有 handler 返回 "not implemented"),缺少 session 存储、callback 解析、token 兑换链路。把 OAuth 后端从 stub 写到可用的体量与本次拆分不成比例,因此从本次范围中拆出,单独立项。
## What Changes
- 把 `App.tsx` 内的 `Login` 组件拆分成独立的 `pages/auth/` 模块(`AdminLoginPage` / `UserLoginPage` 复用同一套 `LoginShell` + 表单原语),合并 `pages/UserLogin.tsx` 的重复实现。
- 新增一个统一的前端登录错误解析层(扩展 `loginError.ts`),覆盖 `/api/auth/login`、`/api/users/login` 两个端点的所有失败码,所有登录入口走同一函数获得文案。
- 统一 `authSession.ts` 中 admin/user 两套 token 的写入路径,登录成功后通过 `commitSession(audience, token)` 单点持久化,避免页面各自调用 `persistAuthSession` / `persistUserSession`。
- 在用户登录页底部预留一个 OAuth 占位区域(不挂任何 provider 按钮),为后续接入 OAuth signin 留出 UI 槽位。
不在本次范围:
- OAuth provider flow 的实际接入(前后端均不实现),由独立 change 跟进。
- 替换 React、引入新状态库、改造桌面端 deeplink、邮箱验证码、注册流程改造。
## Capabilities
### New Capabilities
- `frontend-login`: 前端登录界面与会话建立流程(管理员 token / 账号密码、错误展示、会话持久化、OAuth 占位)。
### Modified Capabilities
<!-- 当前 openspec/specs/ 目录为空,没有既有 spec 受影响。 -->
## Impact
- 前端:`src/web/App.tsx`(拆出 Login)、`src/web/pages/UserLogin.tsx`(薄壳化)、`src/web/loginError.ts`(扩展)、`src/web/authSession.ts`(收敛入口)、新增 `src/web/pages/auth/`。
- 后端:本次不动 `routes/auth.rs` / `routes/oauth.rs`。
- 数据库:本次无迁移。
- 测试:`App.login-surface.test.tsx`、`loginError.test.ts` 需迁移到新组件位置。
- 文档:`README.md` / `AGENTS.md` 中"登录方式"段落需更新(标注 OAuth 由后续 change 接入)。
@@ -1,77 +0,0 @@
## ADDED Requirements
### Requirement: 登录界面拆分为独立模块
前端登录界面 SHALL 由 `src/web/pages/auth/` 目录下的独立模块承担,`App.tsx` 不再内联实现登录表单。`pages/auth/` 至少包含 `LoginShell`(品牌侧栏 + 容器 + 错误槽位)、`AdminLoginPage`(管理员 token / 账密两种 mode)、`UserLoginPage`(用户账密 + OAuth 占位槽)三个组件,单文件不超过 250 行。`pages/UserLogin.tsx` MUST 退化为薄壳,仅渲染 `UserLoginPage`,以保留既有路由 `/user/login`。
#### Scenario: 管理员入口挂载新组件
- **WHEN** 用户访问根路由 `/` 且未登录
- **THEN** 应用渲染 `AdminLoginPage`,且页面来源文件位于 `src/web/pages/auth/AdminLoginPage.tsx`
#### Scenario: 用户入口保留旧路由
- **WHEN** 用户访问 `/user/login`
- **THEN** 应用渲染 `UserLoginPage`,且 `src/web/pages/UserLogin.tsx` 内部仅作 import + 转发,不再持有表单状态
#### Scenario: App.tsx 不再持有登录表单状态
- **WHEN** 在 `src/web/App.tsx` 中查找形如 `setEmail` / `setPassword` / `setToken` 的 useState
- **THEN** 这些 state hooks 不出现在 `App.tsx` 中(已迁移到新组件)
### Requirement: 统一登录错误解析
所有登录入口(管理员 token、管理员账密、用户账密)的失败提示 SHALL 通过 `src/web/loginError.ts` 中的 `resolveLoginErrorMessage(status, reason)` 解析。该函数 MUST 覆盖 400 / 401 / 403 / 409 / 5xx 五类状态,以及 `network` 这类网络异常 sentinel。组件层 MUST NOT 自行 `switch (status)` 写文案。
#### Scenario: 401 返回统一令牌无效文案
- **WHEN** 调用 `resolveLoginErrorMessage(401, "")`
- **THEN** 返回包含"令牌无效"或"账号或密码错误"语义的中文文案
#### Scenario: 网络异常返回统一文案
- **WHEN** 前端 fetch 抛错(无 status 可读),调用 `resolveLoginErrorMessage("network", "")`
- **THEN** 返回"网络异常,请检查连接"文案
#### Scenario: 400 携带 reason 时分流文案
- **WHEN** 调用 `resolveLoginErrorMessage(400, "invalid email format")`
- **THEN** 返回"邮箱格式不合法"文案;当 reason 为空时返回通用"请求参数错误"
### Requirement: 会话写入收敛为单一 API
`src/web/authSession.ts` SHALL 暴露 `commitSession(audience, token, ttlMs?, storage?, now?)` 与 `clearSession(audience, storage?)` 与 `getSessionToken(audience, storage?, now?)` 三个函数。所有登录页 MUST 仅通过这三个函数读写本地存储。`persistAuthSession` 与 `persistUserSession` 仍保留为 `@deprecated` 薄壳调用 `commitSession`。
#### Scenario: 管理员登录成功调用 commitSession
- **WHEN** 管理员账密登录成功,组件拿到 JWT
- **THEN** 组件调用 `commitSession("admin", jwt)`,且不直接调用 `localStorage.setItem`
#### Scenario: 用户登录成功调用 commitSession
- **WHEN** 用户账密登录成功,组件拿到 JWT
- **THEN** 组件调用 `commitSession("user", jwt)`,且不直接调用 `persistUserSession`
#### Scenario: 旧 API 仍可工作
- **WHEN** 现有调用方仍调用 `persistAuthSession(localStorage, token)`
- **THEN** token 仍按原路径写入 `localStorage`,行为与重构前一致
### Requirement: 登录页预留 OAuth 占位槽
`UserLoginPage` SHALL 在表单下方渲染一个名为 `OAuthSlot` 的占位组件作为后续 OAuth 登录入口的挂载点。本次实施 `OAuthSlot` MUST NOT 调用任何 OAuth 相关 API,也 MUST NOT 触发跳转;它只渲染一个带稳定 `data-testid="oauth-slot"` 的容器节点(可包含 i18n 文案 placeholder)。`AdminLoginPage` MUST NOT 包含该槽位。
#### Scenario: 用户登录页渲染占位槽
- **WHEN** 用户访问 `/user/login` 加载 `UserLoginPage`
- **THEN** DOM 中存在 `data-testid="oauth-slot"` 节点,且不发起任何 `/api/oauth/*` 请求
#### Scenario: 管理员登录页不含占位槽
- **WHEN** 用户访问根路由 `/` 加载 `AdminLoginPage`
- **THEN** DOM 中不存在 `data-testid="oauth-slot"` 节点
#### Scenario: 占位槽不触发任何行为
- **WHEN** 用户与 `OAuthSlot` 内任何子元素交互
- **THEN** 不发出网络请求,不修改 `authSession`,不触发路由跳转
@@ -1,48 +0,0 @@
## 1. 基础工具层(无破坏改造)
- [x] 1.1 在 `src/web/loginError.ts` 中扩展 `resolveLoginErrorMessage`,覆盖 400(区分 `invalid email format` / 通用)、409(`no_account`)、`"network"` sentinel;保留现有 401/403/5xx 行为
- [x] 1.2 更新 `src/web/loginError.test.ts`,为新增分支补单测(400+reason、409、network、5xx 已有保留)
- [x] 1.3 在 `src/web/authSession.ts` 新增 `commitSession(audience, token, ttlMs?, storage?, now?)` / `clearSession(audience, storage?)` / `getSessionToken(audience, storage?, now?)`,内部委托给现有 `AUTH_TOKEN_*` / `USER_JWT_*` 路径
- [x] 1.4 把 `persistAuthSession` / `persistUserSession` / `clearAuthSession` / `clearUserSession` 改为薄壳调用 `commitSession` / `clearSession`,加 `@deprecated` JSDoc
- [x] 1.5 扩充 `src/web/authSession.test.ts`,覆盖 `commitSession("admin"|"user")` 双路径与旧 API 仍可工作
## 2. 前端组件拆分
- [x] 2.1 新建目录 `src/web/pages/auth/`,加 `LoginShell.tsx`:抽出品牌侧栏(logo / 文案 / capability list / 页脚链接)+ 容器 + 错误展示槽位,接收 `children` + `error` prop
- [x] 2.2 新建 `src/web/pages/auth/AdminLoginPage.tsx`,承接现有 `App.tsx` 中的 `Login`(admin 分支:token + 账密两种 mode 切换),所有错误走 `resolveLoginErrorMessage`,写入会话调 `commitSession("admin", ...)`
- [x] 2.3 新建 `src/web/pages/auth/UserLoginPage.tsx`,覆盖 `pages/UserLogin.tsx` 当前逻辑,写入会话调 `commitSession("user", ...)`;底部预留一个 `<OAuthSlot />` 占位组件,本次不挂任何 provider 按钮,留给后续 change
- [x] 2.4 把 `src/web/App.tsx` 中 `Login` 组件替换为 `<AdminLoginPage>`,删除组件内部的 useState/handler/JSX;保留 onLogin 回调签名
- [x] 2.5 把 `src/web/pages/UserLogin.tsx` 改为薄壳:`export { default } from './auth/UserLoginPage'` 或 1 行包裹组件
- [x] 2.6 迁移 `App.login-surface.test.tsx` 中 DOM 断言到 `src/web/pages/auth/LoginShell.test.tsx` 与 `AdminLoginPage.test.tsx` / `UserLoginPage.test.tsx`;保留 `App.login-surface.test.tsx` 仅断言挂载入口存在
- [x] 2.7 跑 `npm run typecheck:web && npm run test`,确认无回归
## 3. 清理与验收
- [x] 3.1 删除 `App.tsx` 中已无引用的 helper / 文案常量;用 `rg "import.*from.*authSession"` 巡检无遗漏调用 `persistAuthSession` / `persistUserSession` 的页面
- [x] 3.2 更新 `README.md` 与 `AGENTS.md` 中"登录"段落,加入新目录结构说明(`pages/auth/`);OAuth 登录入口标注"由后续 change 接入"
- [x] 3.3 跑全量校验:`npm run typecheck` / `npm run test` / `cargo test --lib` / `cargo clippy --all-targets`
- typecheck:web、typecheck:web:test 全过;typecheck:server 因 `tsconfig.server.json` 不存在报 TS5058,stash 校验确认 pre-existing
- npm run test:本 change 把 baseline 的 11 files / 17 tests 失败减到 9 files / 13 tests(修好 `App.mobile-layout` 与 `App.sidebar-mobile`),剩余 9 个失败文件均与 authSession 无关、stash 校验确认 pre-existing
- cargo test --lib:78 passed / 3 failed;3 个失败 (`proxy::token_router::router::tests::resolves_exact_model_route` / `…resolves_wildcard_route_and_skips_excluded_channel` / `routes::proxy::tests::chat_route_uses_token_router_and_conductor`) 同样在 stash 后基线复现,pre-existing
- cargo clippy --all-targets:本 change 区块零警告(`comfyui_agent.rs` 测试 mutex 已切到 `tokio::sync::Mutex`);仅余一处 unrelated `clippy::needless_borrow`(`token_router/router.rs:289` 一带)pre-existing
- [ ] 3.4 手测三条登录路径(管理员 token、管理员账密、用户账密);OAuth 占位区域确保渲染但不挂行为
- [x] 3.5 在 PR 描述中列出所有 spec scenarios 的对应测试位置,方便 reviewer 校对(mapping 见下文「附:scenario → test 映射」)
## 附:scenario → test 映射
### specs/frontend-login/spec.md
- 登录界面拆分为独立模块
- 管理员入口挂载新组件 → `src/web/pages/auth/AdminLoginPage.test.tsx`(断言挂载来源 + 路由 `/`)
- 用户入口保留旧路由 → `src/web/pages/auth/UserLoginPage.test.tsx` + `src/web/pages/UserLogin.tsx` 静态包含断言(薄壳)
- App.tsx 不再持有登录表单状态 → `src/web/App.login-surface.test.tsx`(保留为入口断言)+ rg 巡检(CI 时由 grep 守卫脚本兜底)
- 统一登录错误解析
- 401 / 网络异常 / 400+reason → `src/web/loginError.test.ts` 三组用例
- 会话写入收敛为单一 API
- 管理员登录成功调用 commitSession → `src/web/pages/auth/AdminLoginPage.test.tsx`(mock authSession,断言 commitSession('admin', token) 被调用)
- 用户登录成功调用 commitSession → `src/web/pages/auth/UserLoginPage.test.tsx`
- 旧 API 仍可工作 → `src/web/authSession.test.ts`(薄壳兼容用例)
- 登录页预留 OAuth 占位槽
- 用户登录页渲染占位槽 → `src/web/pages/auth/UserLoginPage.test.tsx`(断言 `data-testid="oauth-slot"` 存在 + 无 fetch)
- 管理员登录页不含占位槽 → `src/web/pages/auth/AdminLoginPage.test.tsx`(反向断言)
- 占位槽不触发任何行为 → `src/web/pages/auth/OAuthSlot.test.tsx`(点击无 fetch / 无 commit / 无 navigate)
-20
View File
@@ -1,20 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
@@ -1,92 +0,0 @@
# route-candidate-api Specification
## Purpose
TBD - created by archiving change add-route-rebuild-pipeline. Update Purpose after archive.
## Requirements
### Requirement: `GET /api/models/token-candidates` is registered
The admin router SHALL register `GET /api/models/token-candidates`
behind the standard admin auth layer. The handler SHALL return a JSON
object with exactly four top-level keys: `models`,
`modelsWithoutToken`, `modelsMissingTokenGroups`, `endpointTypesByModel`.
#### Scenario: Endpoint returns 200 with the four-key shape
- **WHEN** an authenticated admin sends `GET /api/models/token-candidates`
- **THEN** the response is HTTP 200 with a JSON body containing keys
`models`, `modelsWithoutToken`, `modelsMissingTokenGroups`, and
`endpointTypesByModel` — no extra top-level keys, no missing keys
#### Scenario: Unauthenticated request is rejected
- **WHEN** a request without a valid admin token reaches the endpoint
- **THEN** the admin auth layer returns 401 before the handler runs
### Requirement: `models` lists every (model, token) candidate
The `models` field SHALL be an object whose keys are model names and
whose values are arrays of candidate descriptors. A `(model, token)`
combination SHALL appear in the `models` array for that model exactly
when ALL of the following hold:
- a row in `model_availability` reports `available = 1` for the model
on the token's account
- the token's `enabled` is truthy (NULL treated as enabled)
- the token's `value_status` is `'ready'` (NULL treated as ready)
- the account's `status = 'active'`
- the site's `status` is `'active'` (NULL treated as active)
Each candidate descriptor SHALL contain: `accountId`, `tokenId`,
`tokenName`, `isDefault` (boolean), `username`, `siteId`, `siteName`.
Within a single model's array, descriptors SHALL be deduplicated by
`tokenId`.
#### Scenario: Active token with available model appears
- **WHEN** account 5 (active, site active) has token 11 (enabled,
ready) and `model_availability(account=5, model='gpt-4')` is
available
- **THEN** `models['gpt-4']` includes a descriptor with
`accountId = 5, tokenId = 11`
#### Scenario: Disabled token is excluded
- **WHEN** account 5 has token 11 with `enabled = 0`
- **THEN** no descriptor with `tokenId = 11` appears in `models` for
any model
#### Scenario: Duplicate tokens in source data deduped
- **WHEN** the underlying join produces two rows for the same
`(model, tokenId)` pair (e.g., joins fan out)
- **THEN** the descriptor appears exactly once in
`models[model_name]`
### Requirement: `modelsWithoutToken` surfaces accounts missing tokens
The `modelsWithoutToken` field SHALL be an object keyed by model name.
For each `(account, model)` combination where the model is available
on the active account but no qualifying token (per the `models`
requirement) exists for that account, the handler SHALL include a
descriptor `{ accountId, username, siteId, siteName }` under that
model name. Descriptors SHALL be deduplicated by `accountId` within
each model's array.
#### Scenario: Account with available model but no enabled tokens
- **WHEN** account 8 reports `gpt-4` available but has zero rows in
`account_tokens` (or all rows are disabled)
- **THEN** `modelsWithoutToken['gpt-4']` includes a descriptor with
`accountId = 8`, and that account does NOT appear in `models['gpt-4']`
#### Scenario: Account with token does not appear here
- **WHEN** account 5 reports `gpt-4` available and has a usable token
- **THEN** account 5 does NOT appear in `modelsWithoutToken['gpt-4']`
### Requirement: `modelsMissingTokenGroups` and `endpointTypesByModel` are empty
The handler SHALL return `modelsMissingTokenGroups` and
`endpointTypesByModel` as empty JSON objects (`{}`). The frontend
contract permits empty objects and these fields SHALL NOT be
populated until a marketplace-cache or pricing-catalog source is
added to the backend.
#### Scenario: Both fields are empty objects
- **WHEN** the handler returns successfully under any state of the
database
- **THEN** `response.modelsMissingTokenGroups === {}` and
`response.endpointTypesByModel === {}`
@@ -1,157 +0,0 @@
# route-rebuild-pipeline Specification
## Purpose
TBD - created by archiving change add-route-rebuild-pipeline. Update Purpose after archive.
## Requirements
### Requirement: Route rebuild reads from `model_availability`
The rebuild pipeline SHALL derive its working set from the
`model_availability` table joined to `accounts`, filtering out:
- accounts whose `status` is not `'active'`
- rows whose `available` is not `1` (treating `NULL` as not available)
- `(account, model)` combinations covered by `site_disabled_models`
for the account's `site_id`
The pipeline SHALL NOT make outbound HTTP requests to upstream platforms
during a rebuild. Refreshing `model_availability` is the responsibility
of the existing `POST /api/accounts/{id}/models/refresh` endpoint and
is out of scope for this requirement.
#### Scenario: Inactive account is excluded
- **WHEN** an account with `status = 'suspended'` has rows in
`model_availability`
- **THEN** no `token_routes` row is created on its behalf and no
`route_channels` row references its `account_id`
#### Scenario: Site-disabled model is excluded
- **WHEN** `site_disabled_models` contains `(site_id = 7, model_name = 'gpt-x')`
and an active account on site 7 has `model_availability` for `gpt-x`
- **THEN** the rebuild does not include that account in the channel set
for the `gpt-x` route
### Requirement: Rebuild aggregates one route per distinct model name
The rebuild SHALL ensure exactly one row exists in `token_routes` for
every distinct `model_name` in the filtered working set, keyed by
`model_pattern = model_name`. The rebuild SHALL reuse existing rows
when their `model_pattern` already matches; new rows SHALL be inserted
with `routing_strategy = 'weighted'`, `route_mode = 'pattern'`, and
`enabled = 1`.
The rebuild MUST NOT overwrite `display_name`, `display_icon`, or any
other fields on existing `token_routes` rows. Operators rely on these
fields being editable from the frontend without being clobbered on
re-runs.
#### Scenario: New model creates a new route
- **WHEN** model `claude-4-opus` appears in the working set and no
`token_routes` row has `model_pattern = 'claude-4-opus'`
- **THEN** the rebuild inserts a `token_routes` row with
`model_pattern = 'claude-4-opus'`, `routing_strategy = 'weighted'`,
`route_mode = 'pattern'`, `enabled = 1`
#### Scenario: Existing route is reused without overwrite
- **WHEN** a `token_routes` row exists with
`model_pattern = 'gpt-4'` and `display_name = 'GPT-4 Turbo (Custom)'`
- **THEN** the rebuild leaves `display_name` unchanged and reuses the
same `id` for channel reconciliation
### Requirement: Channel reconciliation preserves manual overrides
For each route, the rebuild SHALL reconcile `route_channels` rows
keyed by `(account_id, source_model)` against the desired set derived
from `model_availability`. Rows with `manual_override = 1` SHALL be
treated as immutable: not inserted, not updated, not deleted. Rows
with `manual_override = 0` (or `NULL`) SHALL be:
- inserted when missing from the existing set
- hard-deleted when no longer in the desired set
The desired set SHALL exclude any `(account, model)` combination
already covered by a `manual_override = 1` row, so the rebuild never
inserts an auto channel that shadows a manual one.
#### Scenario: Manual channel survives rebuild
- **WHEN** a `route_channels` row has `manual_override = 1` and its
`(account_id, source_model)` is no longer in the desired set
- **THEN** the row is left untouched and counted in
`RebuildSummary.skipped_manual_channels`
#### Scenario: Stale auto channel is deleted
- **WHEN** a `route_channels` row has `manual_override = 0`,
`account_id = 12`, `source_model = 'old-model'`, and no longer
appears in the desired set
- **THEN** the row is deleted and counted in
`RebuildSummary.deleted_channels`
#### Scenario: New auto channel is inserted with default token
- **WHEN** account 12 newly reports `gpt-5` available and has at least
one enabled `account_tokens` row
- **THEN** a new `route_channels` row is inserted with `route_id`
pointing at the `gpt-5` route, `account_id = 12`, the
default-flagged enabled token's id (or the lowest id when none is
default), `priority = 0`, `weight = 10`, `enabled = 1`,
`manual_override = 0`
#### Scenario: Account without tokens still gets a channel
- **WHEN** account 13 reports `gpt-5` available but has no enabled
`account_tokens` rows
- **THEN** a `route_channels` row is inserted with `token_id = NULL`,
relying on the proxy layer's account-level token fallback
### Requirement: Rebuild runs in a single transaction
The rebuild SHALL execute every read and write inside one database
transaction, committed at the end. A partial failure MUST NOT leave
`token_routes` and `route_channels` mutually inconsistent.
#### Scenario: Failure mid-rebuild rolls back
- **WHEN** an `INSERT` into `route_channels` fails partway through
the rebuild
- **THEN** the transaction aborts and no `token_routes` insert from
the same rebuild persists
### Requirement: Rebuild summary matches frontend contract
The rebuild SHALL return a `RebuildSummary` containing the fields
`createdRoutes`, `reusedRoutes`, `createdChannels`, `deletedChannels`,
`skippedManualChannels`, `scannedModels`, `scannedAccounts` (camelCase
when serialized). The `POST /api/routes/rebuild` handler SHALL embed
this object under the JSON key `rebuild`, alongside `success: true`.
`TokenRoutes.tsx:411-412` reads `rebuild.createdRoutes` and
`rebuild.createdChannels`; both MUST be present and SHALL count only
rows actually inserted by the current rebuild (not pre-existing rows
that were reused).
#### Scenario: Rebuild response shape
- **WHEN** the handler completes successfully
- **THEN** the response body is
`{ "success": true, "rebuild": { "createdRoutes": <n1>,
"reusedRoutes": <n2>, "createdChannels": <n3>,
"deletedChannels": <n4>, "skippedManualChannels": <n5>,
"scannedModels": <n6>, "scannedAccounts": <n7> } }`
#### Scenario: Reused route does not increment createdRoutes
- **WHEN** every model in the working set has a pre-existing
`token_routes` row
- **THEN** `createdRoutes = 0` and `reusedRoutes` equals the number of
distinct models scanned
### Requirement: Auto-rebuild after account creation
The system SHALL fire one route rebuild in the background after
`POST /api/accounts` returns success for at least one created
account. The HTTP response SHALL return without waiting for the
rebuild to finish. Errors from the background rebuild SHALL be logged
at `warn` level and SHALL NOT affect the response status or body.
#### Scenario: Single-account create triggers rebuild
- **WHEN** `POST /api/accounts` creates exactly one account
- **THEN** `spawn_route_rebuild` is invoked once with the cloned
`AppState` and the response returns immediately
#### Scenario: Batch-create with all failures does not trigger
- **WHEN** `POST /api/accounts` returns with `created.len() == 0` and
`failed.len() > 0`
- **THEN** `spawn_route_rebuild` is not invoked
+46 -2
View File
@@ -4,10 +4,11 @@ import { getSessionToken } from '../authSession.js';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
const [iframeSrc, setIframeSrc] = React.useState('');
const [starting, setStarting] = React.useState(true);
const location = useLocation();
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const token = getSessionToken(audience, localStorage);
const iframeSrc = token ? `/comfy/?token=${encodeURIComponent(token)}` : '';
React.useEffect(() => {
const el = document.documentElement;
@@ -18,6 +19,49 @@ export default function ComfyUI() {
return () => mo.disconnect();
}, []);
React.useEffect(() => {
let cancelled = false;
const encodedToken = token ? encodeURIComponent(token) : '';
if (!token) {
setIframeSrc('');
setStarting(false);
return () => {
cancelled = true;
};
}
setStarting(true);
setIframeSrc('');
const start = async () => {
try {
const response = await fetch('/api/video-agent/start', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!cancelled && response.ok) {
setIframeSrc(`/comfy/?token=${encodedToken}`);
return;
}
} catch {
// Fall through to the legacy embedded ComfyUI proxy.
}
if (!cancelled) {
setIframeSrc('/api/comfyui/proxy/');
}
};
void start().finally(() => {
if (!cancelled) setStarting(false);
});
return () => {
cancelled = true;
};
}, [token]);
return (
<div
style={{
@@ -47,7 +91,7 @@ export default function ComfyUI() {
color: 'var(--color-text-muted)',
fontSize: 14,
}}>
登录状态已过期,请重新登录。
{starting ? '正在启动 ComfyUI...' : '登录状态已过期,请重新登录。'}
</div>
)}
</div>
+303 -10
View File
@@ -57,10 +57,20 @@ type StreamAccumulator = {
};
type StoredAgentSession = {
activeConversationId?: string;
conversations?: StoredAgentConversation[];
messages?: AgentMessage[];
selectedModel?: string;
};
type StoredAgentConversation = {
id: string;
title: string;
updatedAt: number;
messages: AgentMessage[];
selectedModel: string;
};
const WELCOME_MESSAGE: AgentMessage = {
id: 'welcome',
role: 'system',
@@ -82,6 +92,25 @@ function sessionStorageKey(audience: 'admin' | 'user') {
return `boosapi.comfyuiAgent.${audience}.session`;
}
function buildConversationTitle(messages: AgentMessage[]) {
const firstUser = messages.find((message) => message.role === 'user')?.content.trim();
if (!firstUser) return '新对话';
return firstUser.length > 24 ? `${firstUser.slice(0, 24)}...` : firstUser;
}
function createConversation(
messages: AgentMessage[] = [WELCOME_MESSAGE],
selectedModel = '',
): StoredAgentConversation {
return {
id: createId('conversation'),
title: buildConversationTitle(messages),
updatedAt: Date.now(),
messages,
selectedModel,
};
}
function isValidMessage(value: unknown): value is AgentMessage {
if (!value || typeof value !== 'object') return false;
const item = value as Partial<AgentMessage>;
@@ -97,6 +126,41 @@ function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
const raw = localStorage.getItem(sessionStorageKey(audience));
if (!raw) return {};
const parsed = JSON.parse(raw) as StoredAgentSession;
const conversations: StoredAgentConversation[] = Array.isArray(parsed.conversations)
? parsed.conversations
.map((conversation): StoredAgentConversation | null => {
const messages = Array.isArray(conversation?.messages)
? conversation.messages.filter(isValidMessage)
: [];
if (!conversation?.id || !messages.length) return null;
return {
id: String(conversation.id),
title:
typeof conversation.title === 'string' && conversation.title.trim()
? conversation.title.trim()
: buildConversationTitle(messages),
updatedAt:
typeof conversation.updatedAt === 'number'
? conversation.updatedAt
: Date.now(),
messages,
selectedModel:
typeof conversation.selectedModel === 'string'
? conversation.selectedModel
: '',
};
})
.filter((conversation): conversation is StoredAgentConversation => !!conversation)
: [];
if (conversations.length) {
const activeConversationId = conversations.some(
(conversation) => conversation.id === parsed.activeConversationId,
)
? parsed.activeConversationId
: conversations[0].id;
return { conversations, activeConversationId };
}
const messages = Array.isArray(parsed.messages)
? parsed.messages.filter(isValidMessage)
: undefined;
@@ -112,13 +176,13 @@ function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
function persistStoredSession(
audience: 'admin' | 'user',
messages: AgentMessage[],
selectedModel: string,
conversations: StoredAgentConversation[],
activeConversationId: string,
) {
try {
localStorage.setItem(
sessionStorageKey(audience),
JSON.stringify({ messages, selectedModel }),
JSON.stringify({ conversations, activeConversationId }),
);
} catch {
// Ignore quota/private-mode failures; chat still works for the current page.
@@ -184,8 +248,27 @@ export default function ComfyUIAgent() {
() => readStoredSession(sessionAudience),
[sessionAudience],
);
const initialConversations = useMemo(() => {
if (initialStoredSession.conversations?.length) return initialStoredSession.conversations;
return [
createConversation(
initialStoredSession.messages || [WELCOME_MESSAGE],
initialStoredSession.selectedModel || '',
),
];
}, [initialStoredSession]);
const initialActiveConversation =
initialConversations.find(
(conversation) => conversation.id === initialStoredSession.activeConversationId,
) || initialConversations[0];
const [conversations, setConversations] = useState<StoredAgentConversation[]>(
() => initialConversations,
);
const [activeConversationId, setActiveConversationId] = useState(
() => initialActiveConversation.id,
);
const [messages, setMessages] = useState<AgentMessage[]>(
() => initialStoredSession.messages || [WELCOME_MESSAGE],
() => initialActiveConversation.messages,
);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
@@ -193,7 +276,7 @@ export default function ComfyUIAgent() {
const [status, setStatus] = useState<AgentStatus | null>(null);
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState(
() => initialStoredSession.selectedModel || '',
() => initialActiveConversation.selectedModel || '',
);
const [modelSearch, setModelSearch] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
@@ -224,8 +307,17 @@ export default function ComfyUIAgent() {
useEffect(() => {
const stored = readStoredSession(sessionAudience);
setMessages(stored.messages || [WELCOME_MESSAGE]);
setSelectedModel(stored.selectedModel || '');
const nextConversations = stored.conversations?.length
? stored.conversations
: [createConversation(stored.messages || [WELCOME_MESSAGE], stored.selectedModel || '')];
const nextActive =
nextConversations.find(
(conversation) => conversation.id === stored.activeConversationId,
) || nextConversations[0];
setConversations(nextConversations);
setActiveConversationId(nextActive.id);
setMessages(nextActive.messages);
setSelectedModel(nextActive.selectedModel || '');
setModelSearch('');
setStreaming(null);
setLoading(false);
@@ -234,8 +326,30 @@ export default function ComfyUIAgent() {
}, [sessionAudience]);
useEffect(() => {
persistStoredSession(sessionAudience, messages, selectedModel);
}, [messages, selectedModel, sessionAudience]);
setConversations((current) => {
const now = Date.now();
const next = current.length
? current.map((conversation) =>
conversation.id === activeConversationId
? {
...conversation,
title: buildConversationTitle(messages),
updatedAt: now,
messages,
selectedModel,
}
: conversation,
)
: [createConversation(messages, selectedModel)];
const ordered = [...next].sort((a, b) => {
if (a.id === activeConversationId) return -1;
if (b.id === activeConversationId) return 1;
return b.updatedAt - a.updatedAt;
});
persistStoredSession(sessionAudience, ordered, activeConversationId);
return ordered;
});
}, [activeConversationId, messages, selectedModel, sessionAudience]);
useEffect(() => {
const token = getSessionToken(sessionAudience, localStorage);
@@ -479,6 +593,52 @@ export default function ComfyUIAgent() {
abortRef.current?.abort();
};
const startNewConversation = () => {
if (loading) return;
const conversation = createConversation([WELCOME_MESSAGE], selectedModel || status?.model || '');
setConversations((current) => {
const next = [conversation, ...current];
persistStoredSession(sessionAudience, next, conversation.id);
return next;
});
setActiveConversationId(conversation.id);
setMessages(conversation.messages);
setSelectedModel(conversation.selectedModel || '');
setInput('');
setStreaming(null);
setModelSearch('');
};
const selectConversation = (conversation: StoredAgentConversation) => {
if (loading || conversation.id === activeConversationId) return;
abortRef.current?.abort();
setActiveConversationId(conversation.id);
setMessages(conversation.messages);
setSelectedModel(conversation.selectedModel || '');
setInput('');
setStreaming(null);
setModelSearch('');
};
const deleteConversation = (conversationId: string) => {
if (loading) return;
setConversations((current) => {
const remaining = current.filter((conversation) => conversation.id !== conversationId);
const next = remaining.length ? remaining : [createConversation([WELCOME_MESSAGE], selectedModel)];
const nextActive =
conversationId === activeConversationId
? next[0]
: next.find((conversation) => conversation.id === activeConversationId) || next[0];
setActiveConversationId(nextActive.id);
setMessages(nextActive.messages);
setSelectedModel(nextActive.selectedModel || '');
setInput('');
setStreaming(null);
persistStoredSession(sessionAudience, next, nextActive.id);
return next;
});
};
const renderAssets = (message: AgentMessage | StreamAccumulator) => (
<>
{!!message.images?.length && (
@@ -520,6 +680,70 @@ export default function ComfyUIAgent() {
return (
<div style={pageStyle}>
<aside style={conversationSidebarStyle}>
<div style={conversationSidebarHeaderStyle}>
<strong>对话</strong>
<button
type="button"
onClick={startNewConversation}
disabled={loading}
style={{
...secondaryButtonStyle,
padding: '6px 10px',
opacity: loading ? 0.6 : 1,
}}
>
新建
</button>
</div>
<div style={conversationListStyle}>
{conversations.map((conversation) => (
<button
key={conversation.id}
type="button"
onClick={() => selectConversation(conversation)}
disabled={loading}
style={{
...conversationItemStyle,
borderColor:
conversation.id === activeConversationId
? 'var(--color-primary)'
: 'var(--color-border-light)',
background:
conversation.id === activeConversationId
? 'var(--color-primary-soft, rgba(79, 70, 229, 0.12))'
: 'var(--color-surface)',
}}
>
<span style={conversationTitleStyle}>{conversation.title}</span>
<span style={conversationMetaStyle}>
{Math.max(0, conversation.messages.filter((message) => message.role !== 'system').length)} 条消息
</span>
<span
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
deleteConversation(conversation.id);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
deleteConversation(conversation.id);
}
}}
style={conversationDeleteStyle}
aria-label="删除对话"
>
删除
</span>
</button>
))}
</div>
</aside>
<section style={chatPanelStyle}>
<header style={headerStyle}>
<div>
<h1 style={titleStyle}>视频助手</h1>
@@ -645,6 +869,7 @@ export default function ComfyUIAgent() {
</button>
)}
</footer>
</section>
</div>
);
}
@@ -652,11 +877,79 @@ export default function ComfyUIAgent() {
const pageStyle: CSSProperties = {
height: 'calc(100vh - 56px)',
display: 'flex',
flexDirection: 'column',
flexDirection: 'row',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
};
const chatPanelStyle: CSSProperties = {
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
};
const conversationSidebarStyle: CSSProperties = {
width: 260,
minWidth: 220,
borderRight: '1px solid var(--color-border)',
background: 'var(--color-surface)',
display: 'flex',
flexDirection: 'column',
};
const conversationSidebarHeaderStyle: CSSProperties = {
minHeight: 72,
padding: '14px 14px',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
};
const conversationListStyle: CSSProperties = {
flex: 1,
minHeight: 0,
overflowY: 'auto',
display: 'grid',
alignContent: 'start',
gap: 8,
padding: 12,
};
const conversationItemStyle: CSSProperties = {
width: '100%',
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: 10,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: '4px 8px',
textAlign: 'left',
color: 'var(--color-text-primary)',
cursor: 'pointer',
};
const conversationTitleStyle: CSSProperties = {
gridColumn: '1 / -1',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 13,
fontWeight: 700,
};
const conversationMetaStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-text-muted)',
};
const conversationDeleteStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-danger, #dc2626)',
};
const headerStyle: CSSProperties = {
minHeight: 72,
padding: '14px 20px',