feat: merge opus comfyui and login updates
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-20
|
||||
@@ -0,0 +1,220 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,46 @@
|
||||
## 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` 的人工通道。
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
## 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 === {}`
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
## 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
|
||||
@@ -0,0 +1,52 @@
|
||||
## 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.
|
||||
-->
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-19
|
||||
@@ -0,0 +1,143 @@
|
||||
## 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。
|
||||
@@ -0,0 +1,41 @@
|
||||
## 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`。
|
||||
@@ -0,0 +1,82 @@
|
||||
## 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 的那一行
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
## 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 内容;后续请求仍走本地,不自动切远端
|
||||
@@ -0,0 +1,85 @@
|
||||
## 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 事件
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-20
|
||||
@@ -0,0 +1,102 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,43 @@
|
||||
## 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).
|
||||
@@ -0,0 +1,78 @@
|
||||
## 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
|
||||
@@ -0,0 +1,45 @@
|
||||
## 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
|
||||
@@ -0,0 +1,34 @@
|
||||
## 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
|
||||
@@ -0,0 +1,41 @@
|
||||
## 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
|
||||
@@ -0,0 +1,69 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-19
|
||||
@@ -0,0 +1,81 @@
|
||||
## 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 再决定。
|
||||
@@ -0,0 +1,34 @@
|
||||
## 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 接入)。
|
||||
@@ -0,0 +1,77 @@
|
||||
## 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`,不触发路由跳转
|
||||
@@ -0,0 +1,48 @@
|
||||
## 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)
|
||||
Reference in New Issue
Block a user