fix: address PR review feedback

This commit is contained in:
cita-777
2026-09-04 15:58:39 +08:00
parent 1719e62f09
commit 28c48dfd76
37 changed files with 337 additions and 1742 deletions
+1
View File
@@ -25,4 +25,5 @@ docker/.env
# Local planning docs
docs/plan/
docs/plans/
/.trellis/tasks/
.ace-tool/
@@ -1,3 +0,0 @@
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"检查取消和 cleanup"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"检查性能证据和行为回归"}
{"file":".trellis/spec/guides/documentation-language.md","reason":"检查新增任务说明是否遵循中文文档约定"}
@@ -1,40 +0,0 @@
# 前端请求性能与用户体验:设计
## 异步资源模型
保持页面本地状态,新增的 hook 只拥有请求生命周期,不拥有领域数据缓存:
```ts
type AsyncLoader<T> = (signal: AbortSignal) => Promise<T>;
type AsyncResource<T> = {
data: T | null;
loading: boolean;
error: Error | null;
reload: () => void;
cancel: () => void;
};
```
实现可用 AbortController + sequence ref;`api.ts` 负责认证、timeout、错误
提取和 SSE,页面负责 partial failure、silent refresh 和 mutation invalidation。
不要把每个页面的 filter/sort 派生结果存成第二份可变状态。
## 轮询与并发
- Dashboard/ProxyLogs 用单飞锁或 abort previous,保证慢请求不会叠加;
- visibilitychange 时停止定时器,重新可见时最多立即刷新一次;
- SearchModal debounce timer、请求 controller 和关闭/卸载都清理;
- `probeSiteSpeed` 使用固定大小 worker pool、每站超时和结构化结果,不改变
no-cors 的跨站限制。
## 流式渲染
ModelTester 累积 delta 和 debug raw 文本到 ref/队列,在 rAF 或 16–50ms 窗口
内批量提交 React state;完成、停止、错误、abort/unmount 都先 flush 再清理,
确保顺序、重复短 delta、工具调用和 debug 内容不变。
## Chunk 与可见性
保留已有路由 lazy loading。对 vchart 只做可测量的 below-fold gate 或更细
入口拆分,不把同一个库复制进多个 vendor chunk;以 network request、LCP/TTI
近似指标和图表出现后的布局稳定性验收。
@@ -1,5 +0,0 @@
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"异步取消、timer 和 stale guard"}
{"file":".trellis/spec/frontend/state-management.md","reason":"保持页面本地 server state"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"性能/UX 回归和测试门"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"API/SSE/跨域测速边界"}
{"file":".trellis/spec/guides/documentation-language.md","reason":"保持 Trellis 实施文档和清单使用简体中文"}
@@ -1,25 +0,0 @@
# 前端请求性能与用户体验:执行清单
1. 记录 baseline(build chunk、页面请求时间线、自动刷新并发、Search stale、
ModelTester render/flush 计数),建立可重复的 fake API/SSE fixtures。
2. 实现并测试 `useAsyncResource`/等价 helper,先迁移 SearchModal 和一个简单
页面,再迁移 Dashboard、ProxyLogs。
3. 修复 Dashboard visibility/single-flight、ProxyLogs 2 秒刷新锁、SearchModal
debounce abort、Models hydrate timer cleanup。
4. 抽取 Dashboard `probeSiteSpeed`,加入并发池、超时和逐站结果;保留明确的
cross-origin exception 注释和 drift allowlist。
5. 为 ModelTester 流式更新加批处理并做内容等价测试。
6. 评估 ProxyLogs/Accounts progressive list 与图表可见性门控,只有有数据才
实施;补移动/键盘/空态/错误回归。
7. 生成前后性能报告并运行:
```bash
npm test -- src/web/components/search-modal.results.test.tsx src/web/pages/dashboard.*.test.tsx src/web/pages/ProxyLogs.server-driven.test.tsx
npm run typecheck:web
npm run build:web
npm run repo:drift-check
git diff --check
```
停止条件:内容顺序或协议字段变化、后台轮询继续堆积、跨域测速丢失、或无法
证明性能收益时,保留基线并停止该优化,不扩大重构。
@@ -1,60 +0,0 @@
# 前端请求性能与用户体验
## 目标
在不改变页面能力和 API 契约的前提下,统一可取消/防陈旧的请求生命周期,
降低重复请求和无谓渲染,改善大列表、图表、搜索、轮询和模型测试器的响应,
并让加载、空态、错误和取消反馈在页面间一致。
## 已确认事实
- 规划阶段的 `npm run build:web` 采样为 `2613 modules transformed`、约 `5.35s`;
`vchart-vendor` 为 `2,154.02 kB`(gzip `586.81 kB`),是唯一明显超大的 chunk。
最终交付复核为 `2614 modules`、CSS `136.83 kB`,详见项目审计;旧的 2,548
modules / 5.30s 数字只作为历史材料。
- Dashboard 首屏有三组数据请求,可见性轮询没有 in-flight single-flight;
ProxyLogs 每 2 秒刷新但没有请求并发锁;SearchModal debounce 没有 abort/
sequence;Models hydrate timer 未完整清理。
- ModelTester 对每个 SSE chunk 更新消息和 debug 状态,可能造成整树高频渲染;
Dashboard 对所有站点测速使用无上限 `Promise.all`。
- TokenRoutes 已有 progressive chunk + IntersectionObserver,可作为评估样例;
规范要求继续使用 React state + `api.ts`,不新增全局 server-state 库。
- 当前工作树的 `useAsyncResource`、`siteSpeedProbe` 和 `streamBatcher` 是未提交
候选/WIP;在 focused tests、typecheck、build 和 review 通过前,不视为已完成
的产品能力。
## 需求
1. 提供窄职责、类型安全的可取消异步 hook/helper,支持 stale guard、silent
refresh、AbortError/timeout 区分和 unmount cleanup;迁移至少四个页面路径。
2. 修复 SearchModal、Dashboard、ProxyLogs、Models 的竞态、timer 和轮询行为;
页面切后台时停止高频轮询,回到前台只做一次刷新。
3. 将 Dashboard 跨域测速抽成共享 `probeSiteSpeed`/等价 helper,限制并发、
超时和逐站结果映射,保留 no-cors 特殊能力并记录架构例外。
4. 对 ModelTester 流式 delta/debug 更新做 16–50ms 批处理,停止/abort 时
flush,保证最终文本和 debug 字节序列不变。
5. 只在测量证明必要时复用 progressive list 或图表可见性门控,避免首屏提前
下载 vchart vendor;保证图表出现时无布局跳动和功能缺失。
6. 统一选定页面的 loading/empty/error/cancel feedback 和翻译,不把错误吞掉。
7. 输出前后 chunk、请求在途数、stale response 和关键交互测量报告。
## 验收标准
- [ ] async helper 有 abort、stale、silent refresh、unmount 和错误分类测试;
至少四个真实页面使用它。
- [ ] SearchModal 快速 q1→q2 只显示 q2;Dashboard/ProxyLogs 自动刷新在慢
请求下最多一个在途请求;停止/卸载后没有残留请求或 timer。
- [ ] Dashboard 测速并发上限可测试(目标不超过 4),逐站失败可见,跨域能力
不丢失。
- [ ] ModelTester 内容完全一致且渲染频率/长任务指标改善或有证据说明瓶颈
已转移;首字响应不出现可感知回退。
- [ ] 首屏/路由 chunk 不回归,below-fold 图表不会无条件提前下载超大 vendor;
图表可见后仍正常渲染。
- [ ] 桌面、移动、键盘、翻译、加载/空态/错误/取消行为有回归测试。
- [ ] web typecheck、focused/full tests、drift check、build 和 diff check 通过。
## 范围外
- 引入 React Query/SWR/Zustand 等全局数据层;
- 改变服务器 API 响应、代理协议、分页语义或删除功能;
- 在没有测量的情况下对所有列表做窗口化或重写所有页面。
@@ -1,26 +0,0 @@
{
"id": "frontend-performance-ux",
"name": "frontend-performance-ux",
"title": "前端请求性能与用户体验",
"description": "统一可取消异步状态,优化请求与渲染性能和跨页面体验,同时保持现有行为。",
"status": "planning",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "cita-777",
"assignee": "cita-777",
"createdAt": "2026-08-31",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-31-project-wide-simplification",
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -1,120 +0,0 @@
# 全项目简化基线审计(2026-08-31)
## 执行环境与命令
- Node `v22.23.1`、npm `10.9.8`。
- `npm run typecheck:web`:通过。
- `npm run build:web`:当前工作树最终复核通过,`2614 modules transformed`,约 `4.77s`。
- web CSS:`136.83 kB`(gzip `22.01 kB`);`vchart-vendor`:`2,154.02 kB`
(gzip `586.81 kB`)。
- `npm run repo:drift-check`:当前工作树为 `0 violations / 0 tracked debt`;同一
checker 只读扫描 `HEAD` 为 `5 violations / 0 tracked debt`。这里的两种口径必须
分开记录,不能把当前未提交 WIP 的结果写成历史基线。
- 本审计只读,不修改产品源码;当前工作树的 `.agents/`、`.codex/`、
`.trellis/`、`graphify-out/`、`output/` 等无关文件保持原状。
## 前端结构与样式
| 观察 | 证据 | 影响 |
| --- | --- | --- |
| 路由已经 lazy load | `src/web/App.tsx:22-39` | 不应重复引入第二套路由/页面技术栈 |
| 全局 CSS token/class 是主样式来源 | `src/web/index.css:1-70`、6,908 行、62 个 CSS variables | 适合继续作为 Metapi 视觉 owner |
| Tailwind 入口存在但 utility 实际使用很少 | `vite.config.ts:1-18`、`index.css:1`;生产扫描仅命中极少工具类 | 存在冗余构建入口,应在阶段 A 以 build 证据决定清理 |
| 共享 modal/drawer/select 已存在 | `components/CenteredModal.tsx`、`MobileDrawer.tsx`、`ModernSelect.tsx` | 应保留 API 做 facade,而非一次性重写 |
| 约 430 个原生 button、无统一 Button | 高频集中于 Accounts/Sites/Settings/OAuth/Tokens | 重复 disabled/spinner/aria 组合,易产生视觉和行为漂移 |
| inline style 热点 | Settings 192、ProxyLogs 185、Sites 134、Models 119、Accounts 109、ModelTester 108 | 先抽高频布局/反馈 primitive,避免机械全量替换 |
| 超大页面 | ProxyLogs 3,832、Accounts 3,499、ModelTester 3,401、Settings 2,647、Sites 2,426 | 状态、编排、渲染混在单文件,适合按领域拆分 |
## UI/无障碍重复
- `ChangeKeyModal`、`SearchModal`、settings 多个 modal、下游/OAuth drawer 各自
复制 portal/backdrop/动画/滚动锁;部分缺 `role`、`aria`、focus return。
- `TokenRoutes.tsx` 有 `document.createElement + innerHTML` imperative dialog;
`window.confirm` 分散在 ImportExport、OAuthManagement、Settings、TokenRoutes。
- `ModernSelect` 的 `options/onChange` 和组件类型被测试直接断言,必须先做
facade,再考虑内部 Radix Select。
- Toast 已有集中 provider,但缺 `aria-live` 和完整 timer cleanup。
## 请求与性能热点
- `Dashboard.tsx:250-319` 首屏三组请求;可见性轮询没有在途锁。
- `ProxyLogs.tsx:975-1020,1084-1090` 有 sequence guard 但 2 秒刷新可能堆积
慢请求。
- `SearchModal.tsx:78-121` debounce 没有 abort/sequence,旧结果可能覆盖新查询。
- `Models.tsx:189-240` 的延迟 metadata hydrate timer 需要卸载清理。
- `ModelTester.tsx:1740+` 每个 SSE chunk 都 setState,适合批处理但必须验证
文本顺序、工具调用和 debug 字节不变。
- `Dashboard.tsx:1347-1443` 对每个站点用 `Promise.all` 做跨域 `no-cors` 测速;
该能力不能伪装成认证 API,应抽成有限并发 helper 并保留明确例外。
- `vchart-vendor` 2,154.02 kB(gzip 586.81 kB)是唯一超过 500 kB 的 chunk;
先做 below-fold 可见性测量,再决定是否拆入口。
## 服务端重复与边界债务
- `src/server/routes/api/accounts.ts:721-830,966-1055` 两套验证诊断同构,
需统一 shield、user-id、Bearer/Cookie、endpoint pool 和网络错误语义。
- `src/server/services/platforms/oneApi.ts:57-98` 与 `veloera.ts:34-75` 重复
check-in、balance、models/context cache,差异可配置化。
- `geminiSurface.ts:247-299`、`routes/proxy/completions.ts:372-425`、
`embeddings.ts:217-270`、`images.ts:461-514`、`search.ts:217-268` 重复
proxy log 编排,应统一结构化 owner 并保留 path/usageSource/billing 默认值。
- `HEAD` 的 drift report 有 5 条违规:4 条 proxy-core → routes/proxy import、
1 条 Accounts → Tokens page import;当前工作树 checker 已暂时消除这些路径,
但只有迁移真实完成并经 review 后才可把它当作稳定结论。
## 已确定方向与限制
- 采用 Iris-like 的 Radix 原语 + 项目封装层,不引入 Arco/Ant Design 全套视觉
组件;页面和领域组件不直接 import 第三方 UI。
- 保留 Metapi 现有靛蓝/灰色视觉 token 和业务组件,不复制 Iris 暖色主题。
- 不新增 React Query/SWR/Zustand 全局 server-state 层,不改数据库 schema、
代理协议、公开 API 或用户能力。
- 跨域测速、登录 bootstrap 等 raw fetch 只能作为有 owner、有注释的例外,不能
变成新页面的默认模式。
## 审计局限
- 未在本机运行真实浏览器性能 trace 或生产网络;chunk/request 指标是本地构建
和静态/测试夹具基线。
- live MySQL/PostgreSQL 服务未作为本任务前置条件;服务端阶段不改 schema。
- 现有工作树的用户 dirty/untracked 内容不属于本审计,不能据此推断其意图。
## 当前工作树候选核验与 owner 映射(2026-08-31)
下表描述当前 dirty worktree 中已出现的候选切片,不表示已提交、已发布或父/子
任务全部完成;未提交的产品代码仍由各自任务负责,必须在任务级 quality gate
完成后才能升级为交付结论。这里仍然坚持有明确 owner 和回归证据,未把“文件变少”
当成协议统一的充分理由:
| 交付物 | 唯一 owner | 证据与边界 |
| --- | --- | --- |
| Dialog/Drawer/Button/feedback | `src/web/components/ui/**` | role/aria、focus return、Escape/backdrop、body-lock 多实例和 loading 行为测试;`CenteredModal`/`MobileDrawer` 保留 facade |
| ModernSelect | `src/web/components/ui/Select.tsx` | 保留 `options/value/onChange/placeholder` 与 listbox 键盘语义;仍是项目兼容 facade,尚未声称迁移 Radix Select |
| 异步请求生命周期 | `src/web/components/useAsyncResource.ts` + `src/web/api.ts` | abort、stale、silent refresh、single-flight、timeout/body/SSE cleanup 测试;页面继续拥有领域 state |
| Dashboard 测速 | `src/web/pages/helpers/siteSpeedProbe.ts` | 固定并发上限 4、逐站 timeout/result、`no-cors` browser-only 例外和可见按钮状态测试 |
| ModelTester 流式更新 | `src/web/pages/model-tester/streamBatcher.ts` | 24ms 批处理;done/stop/abort/unmount flush,cumulative delta 与 raw event 顺序等价测试 |
| Tokens 页面边界 | `src/web/pages/tokens/TokensPanel.tsx` | `Tokens.tsx` 仅 legacy redirect/兼容导出;解除 top-level page-to-page import |
| 账号验证诊断 | `src/server/services/accountVerificationDiagnostics.ts` | Bearer/Cookie、shield、user-id、endpoint/timeout/no-response 结构化诊断与 route envelope 回归 |
| proxy-core helper | `src/server/services/downstreamPolicyService.ts`、`multipart.ts` | proxy-core 不再依赖 `routes/proxy/**`;旧路径仅 re-export facade,并有 architecture test |
候选切片此前记录的门禁结果(仅作为当前工作树证据,需在最终任务检查时重跑):
- Web:157 个测试文件、539 个测试通过,2 个跳过;
- Server:301 个测试文件、2,155 个测试通过,6 个跳过;
- 全仓库 `npm test`:480 个测试文件通过、2,777 个测试通过,1 个文件/8 个测试跳过;
- `npm run typecheck`、`npm run build`、`npm run repo:drift-check`、
`git diff --check` 曾通过;当前 checker 的 drift 为 `0 violations / 0 tracked debt`;
- `better-sqlite3` 首次受 Node ABI 不匹配影响,执行 `npm rebuild better-sqlite3`
后再运行 server suite;该操作只改 `node_modules`,没有改仓库产物。
仍明确保留的后续项和未决风险:
1. `vchart-vendor` 约 2.15 MB(gzip 约 587 kB)仍是最大 chunk。当前没有真实
浏览器 below-fold/network trace,因此不实施未经测量的图表拆分;后续任务须
提供首屏请求、LCP/TTI 和布局稳定性证据。
2. OneApi/Veloera 全面继承统一与全部 proxy-log owner 迁移暂缓。header、quota
divisor、check-in 文案、model error propagation、usage/billing 默认值和
await 时序存在真实差异;继续合并前必须先补参数化协议回归。
3. `testChatStream()` 保留 raw `fetch()`,因为它有意返回原始 `Response` 并由
调用方拥有 body;跨域测速和登录 bootstrap 同样是已记录的 browser-only/API
boundary 例外,不应被全面禁止规则破坏。
@@ -1,9 +0,0 @@
{"file":".trellis/spec/guides/documentation-language.md","reason":"检查任务验收和实现变更中的 Trellis 文档是否遵循简体中文默认语言,以及机器可读字段是否保持不变。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "检查前端验证门和回归风险"}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "检查服务端边界和测试要求"}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查跨层回归与契约漂移"}
{"file": ".trellis/tasks/08-31-project-wide-simplification/audit.md", "reason": "核对实现是否回应已记录的热点和边界"}
{"file": ".trellis/tasks/08-31-project-wide-simplification/research/repository-baseline.md", "reason": "核对构建、drift、Git dirty 状态和数据库范围"}
{"file": ".trellis/tasks/08-31-project-wide-simplification/research/frontend-architecture.md", "reason": "核对页面、请求、样式和候选性能改动边界"}
{"file": ".trellis/tasks/08-31-project-wide-simplification/research/backend-architecture.md", "reason": "核对服务端继承关系、proxy log owner 和跨层债务"}
{"file": ".trellis/tasks/08-31-project-wide-simplification/research/trellis-runtime.md", "reason": "核对 workflow、session pointer、JSONL 和 hook 运行时契约"}
@@ -1,173 +0,0 @@
# 全项目简化与前端一致性:技术设计
## 1. 设计目标与非目标
本任务的目标是把已经存在的重复行为收敛到明确的 owner,降低页面和服务
文件的编排复杂度,并让后续新增页面只能沿用同一套组件、样式和请求边界。
所有重构都必须保持现有路由、协议字段、数据库语义、权限语义和用户可见能力。
本任务不做全量 UI 换肤,不把业务页面改造成第三方模板,不引入全局状态库来
替代页面状态,也不借“简化”之名删除功能或跳过现有兼容测试。
## 2. 目标架构
### 2.1 Web 组件边界(Iris 参考模式)
```text
路由页 pages/**
↓ 只编排数据、URL intent 和领域状态
领域组件 pages/<domain>/**
↓ 只使用项目自己的语义 props
项目 UI 封装 components/ui/**
↓ 唯一允许直接引用第三方 UI 原语
Radix primitives + Metapi CSS tokens/classes
```
- Radix 只负责 Dialog、Select、Tabs、Popover 等交互、焦点和无障碍原语;
不让路由页直接 import `@radix-ui/*`。
- `components/ui/**` 是稳定的项目 API。先保留现有
`CenteredModal`、`MobileDrawer`、`ModernSelect` 的导出和 props 兼容,再
在内部逐步委托给 Radix,避免一次改变所有页面和测试树。
- Metapi 继续使用现有靛蓝/灰色 CSS 变量和语义 class。Iris 的 Tailwind
是参考项目的实现细节,不要求 Metapi 复制其暖色 token 或整体迁移到
Tailwind。
- `Button`/`AsyncButton`、`Dialog`/`Drawer`、`Select`、`FormField`、
`AsyncState`、`EmptyState`、`ErrorState` 等基础组件由项目封装层统一
拥有。业务卡片、路由编辑器、模型测试器和图表继续由领域模块拥有。
### 2.2 样式入口
当前生产代码只有极少量真正的 Tailwind utility 使用,大部分页面依赖
`src/web/index.css` 的 token/class 和 inline style。第一阶段先用静态搜索和
构建产物确认这一事实;若确认没有必须保留的 utility,移除 Tailwind Vite
插件、CSS import 和依赖,并把现有 CSS token/class 明确记录为唯一样式入口。
若搜索发现实际依赖,则保留 Tailwind,但必须将其设为唯一 utility 入口并
禁止新增另一种 CSS 运行时。无论结果如何,页面都不能各自选择技术栈。
### 2.3 异步请求和页面状态
仓库不引入 React Query/SWR 作为全局 server-state 层(现有规范明确页面状态
由 React state + `api.ts` 管理)。新增一个窄职责的可取消异步 hook,例如:
```ts
type AsyncLoader<T> = (signal: AbortSignal) => Promise<T>;
type AsyncResource<T> = {
data: T | null;
loading: boolean;
error: Error | null;
reload: () => void;
cancel: () => void;
};
```
具体命名以实现前搜索结果为准,但必须满足:
- 每次 target/filter 变化都会取消或使旧请求失效;
- `AbortError`、超时和真实失败在 UI 中可区分;
- silent refresh 不清空已经可用的数据;
- 页面仍然决定自己的 partial-failure 和 mutation reload 语义;
- 不把派生过滤结果再复制成第二份可变 server state。
`src/web/api.ts` 继续是认证、超时、错误提取和 SSE 的唯一边界。跨域 no-cors
的站点测速是明确的特殊能力,必须由单独的 `probeSiteSpeed` helper 统一
并发、超时和结果映射,而不是把它伪装成普通管理 API。
### 2.4 服务端所有权
- 账号验证诊断下沉到中性 service(建议
`src/server/services/accountVerificationDiagnostics.ts`),route 只负责
base URL/endpoint pool 选择和 HTTP 响应适配。
- 代理日志写入通过结构化 `recordProxyLog` owner 统一,保留不同 endpoint
的 path、usageSource、billing/token 默认值和 warning scope。
- OneApi/Veloera 目前都直接继承 `BasePlatformAdapter`,不是
`StandardApiProviderAdapterBase` 的子类。若要收敛共同流程,先抽取中性策略或
helper,并用 header、quota divisor、models fallback、check-in capability 的
参数化测试证明兼容;不得凭空改继承树。
- `proxy-core` 不再新增对 `routes/proxy` 的依赖。当前 `HEAD` 仍有 5 条违规:4 条
proxy-core → routes/proxy,1 条 Accounts → Tokens page;当前工作树有对应的
中性 service/facade 候选,但只有通过完整检查后才能删除 drift allowlist。
- Accounts 的 `TokensPanel` 迁移到领域子目录,解除 top-level page 互相 import 的
一项边界债务;是否稳定纳入交付仍以 architecture test 和 drift 结果为准。
## 3. 分阶段交付
### 阶段 A:UI 基础与边界守卫
1. 引入最小 Radix 依赖并建立 `components/ui` wrapper;保留旧组件 API。
2. 统一 Dialog/Drawer 的 role、aria、焦点返回、body-scroll 引用计数、
Escape/backdrop 策略和动画退出行为。
3. 将复制的 settings modal、Search/ChangeKey modal、下游/OAuth drawer、
`window.confirm`/imperative dialog 逐步改为 wrapper;优先改高风险和
低业务耦合路径。
4. 以 facade 方式改造 `ModernSelect`,先保持 `options/onChange` 和测试中
的组件类型,再逐步替换内部实现。
5. 增加 `Button`/`AsyncButton` 和统一反馈 primitive,按页面增量迁移。
6. 扩展 drift check:第三方 UI 直引、认证页面 raw fetch、超大页面 ratchet;
允许项必须有注释和 tracked-debt 记录。
### 阶段 B:请求、性能与 UX
1. 先迁移 SearchModal、Dashboard、ProxyLogs 三个高收益路径到可取消/单飞
请求模式;再迁移至少一个普通管理页验证可复用性。
2. 修复 Dashboard 可见性轮询、ProxyLogs 2 秒自动刷新、SearchModal debounce
竞态和 Models hydrate timer 清理。
3. 把 Dashboard 站点测速改为有上限的并发池,保留跨域 no-cors 能力和逐站
失败反馈。
4. 对 ModelTester 流式 delta/debug 更新做 16–50ms 的 rAF/批处理,停止或
abort 时强制 flush,保持字节级内容一致。
5. 复用 TokenRoutes 的 progressive chunk/IntersectionObserver 模式评估
ProxyLogs/Accounts 大列表;只有测量证明有益时才引入窗口化。
6. 对 below-fold 图表增加可见性门控或更细粒度 chunk,目标是首屏不提前
下载 2.15MB vchart vendor;图表出现时仍必须正常挂载。
7. 把高频重复 inline style、按钮和 empty-state markup 迁移到 wrapper/class,
不进行机械式全量替换;每次替换都保留视觉和响应式测试。
### 阶段 C:服务端重复与跨层债务
1. 合并 accounts 两套验证诊断并保留 endpoint pool、网络错误和失败词汇
语义。
2. 核对 `OneApiAdapter`/`VeloeraAdapter` 的真实继承和差异;先以中性策略与
参数化 adapter 测试验证,再决定是否收敛共同流程,不强行改为
`StandardApiProviderAdapterBase` 子类。
3. 统一 completions/embeddings/images/search/Gemini 的 proxy log 写入。
4. 迁移 `downstreamPolicy`、multipart helper 到中性 owner,验证并删除对应的
proxy-core drift 违规;抽取 TokensPanel 消除页面互引。
5. 对所有跨层改动运行 route/service/transformer architecture tests,确认
不改变协议转换和数据库行为。
## 4. 兼容性与回滚
- wrapper 优先保持现有导出名、props 和 CSS class;旧组件可作为 facade,
以便逐页回滚。
- 每个阶段以独立 commit/子任务交付,出现视觉、焦点或协议回归时只回滚
对应 wrapper/迁移 commit,不回滚用户已有的无关工作。
- 不做数据库迁移;若发现必须改 schema,暂停本任务并另建 schema 需求。
- 不把 `window.confirm` 的同步调用直接替换成未设计好的异步状态;先保留
过渡 helper 或逐个重写调用方及测试。
- 跨域测速和登录 bootstrap 的 raw fetch 例外必须在架构报告中标注原因、
owner 和后续迁移状态,不能用“全面禁止”破坏能力。
## 5. 可观测基线与验收数据
当前已验证:
- `npm run typecheck:web`:通过(Node v22.23.1 环境);
- 规划阶段 `npm run build:web` 采样为 `2613 modules transformed`,约 `5.35s`;
这是 Node `v22.23.1` 的本地基线,不是发布环境证明。最终复核见任务审计和
`docs/frontend-architecture.md`。
- 规划阶段 web CSS:`136.78 kB`(gzip `22.00 kB`);
- 最大 chunk:`vchart-vendor` `2,154.02 kB`(gzip `586.81 kB`);
- 其他较大路由 chunk:TokenRoutes `176.61 kB`、Settings `105.96 kB`、
ModelTester `88.98 kB`、DownstreamKeys `65.48 kB`、Accounts `60.73 kB`、
Sites `59.67 kB`、ProxyLogs `58.56 kB`;
- 当前工作树运行 `npm run repo:drift-check`:`0 violations / 0 tracked debt`;
同一 checker 只读扫描 `HEAD` 时为 `5 violations / 0 tracked debt`,不能把两种
口径混写成“历史 debt 已清零”。
- inline `style={{` 热点(文件级近似):Settings 192、ProxyLogs 185、
Sites 134、Models 119、Accounts 109、ModelTester 108;
- 页面行数热点:ProxyLogs `3,832`、Accounts `3,499`、ModelTester `3,401`、
Settings `2,647`、Sites `2,426`、TokenRoutes `2,041`、Dashboard `1,710`。
验收必须同时记录构建 chunk、请求在途数、stale response、关键交互和测试
结果;单纯“编译成功”不视为性能或 UX 完成。
@@ -1,16 +0,0 @@
{"file":".trellis/spec/guides/documentation-language.md","reason":"确保任务实现阶段新增或修改的 Trellis 文档默认使用简体中文,并保留命令、路径和机器字段的规范拼写。"}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "约束抽象边界和避免重复实现"}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "约束跨层契约和数据流审计"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Iris-like wrapper 与无障碍组件规范"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "异步 hook、cleanup 和 stale guard 规范"}
{"file": ".trellis/spec/frontend/state-management.md", "reason": "页面状态与 API 边界规范"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端测试、漂移和可访问性门"}
{"file": ".trellis/spec/backend/directory-structure.md", "reason": "服务端所有权边界"}
{"file": ".trellis/spec/backend/error-handling.md", "reason": "错误、重试和 best-effort 语义"}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "服务端质量与架构守卫"}
{"file": "docs/plans/2026-03-23-single-source-consolidation-mega-plan.md", "reason": "避免重复已交付的历史整合"}
{"file": ".trellis/tasks/08-31-project-wide-simplification/audit.md", "reason": "当前仓库证据、基线指标和审计局限"}
{"file": ".trellis/tasks/08-31-project-wide-simplification/research/repository-baseline.md", "reason": "仓库规模、构建、Git、数据库和验收基线"}
{"file": ".trellis/tasks/08-31-project-wide-simplification/research/frontend-architecture.md", "reason": "前端页面边界、请求例外、性能热点和候选 helper"}
{"file": ".trellis/tasks/08-31-project-wide-simplification/research/backend-architecture.md", "reason": "服务端 adapter、proxy-core、日志 owner 和 drift 事实"}
{"file": ".trellis/tasks/08-31-project-wide-simplification/research/trellis-runtime.md", "reason": "Trellis workflow、任务生命周期、JSONL、workspace 和 hook 契约"}
@@ -1,124 +0,0 @@
# 全项目简化与前端一致性:执行计划
## 执行前检查
- [x] 已读取根 `AGENTS.md`、frontend/backend Trellis specs、代码复用和跨层
思考指南。
- [x] 已检索历史 single-source consolidation,避免重复已交付工作。
- [x] 已建立 web typecheck、web build、drift check 和 chunk 基线。
- [x] 已确认用户选择 Iris-like:Radix 原语 + 项目封装层。
- [ ] 在 `task.py start` 前完成最终规划评审;未获评审批准不得改产品代码。
## 子任务顺序
### 1. `08-31-frontend-ui-foundation`
目标:建立唯一的 `components/ui` 边界,修复共享弹窗/抽屉/选择器的可访问
性和生命周期问题,并建立防止第三方 UI 直引的 architecture guard。
建议顺序:
1. 确认 Radix 最小依赖集合,更新 npm/pnpm lockfile 并验证 React 18 兼容;
2. 新增 Dialog/Drawer wrapper,保留 `CenteredModal`/`MobileDrawer` facade;
3. 为多个 portal 的 body lock、focus return、Escape/backdrop 写回归测试;
4. 迁移 SearchModal、ChangeKeyModal、settings modal、下游/OAuth drawer;
5. 迁移 `window.confirm` 和 TokenRoutes imperative dialog,覆盖 remember/
dismiss 语义;
6. 以 facade 改造 `ModernSelect`,再增量加入 `Button`/`AsyncButton` 与反馈
primitive;
7. 清理已确认未使用的 Tailwind 入口(或记录保留理由),建立 wrapper import
architecture test 和 drift rule;
8. 运行组件及页面焦点、移动布局、类型和构建测试。
风险文件:`src/web/components/CenteredModal.tsx`、`MobileDrawer.tsx`、
`ModernSelect.tsx`、`Toast.tsx`、`src/web/App.tsx`、
`src/web/pages/TokenRoutes.tsx`、settings/downstream/oauth 子目录。
### 2. `08-31-frontend-performance-ux`
依赖阶段 1 的 wrapper/API 边界,但不要求等待所有页面 UI 迁移完成。
建议顺序:
1. 新增并测试窄职责 `useAsyncResource`/`useCancellableLoad`(最终命名按
代码搜索结果决定),覆盖 abort、stale、silent refresh、unmount;
2. 迁移 SearchModal、Dashboard、ProxyLogs 和一个普通管理页;
3. 抽取 `probeSiteSpeed` 并将 Dashboard 站点测速限制并发、统一 timeout/result;
4. 修复 Dashboard/ProxyLogs 自动刷新 single-flight 和 visibility 行为;
5. 修复 Models hydrate timer,给 ModelTester 流式更新加批处理并验证内容
完整性;
6. 仅在测量证明必要时复用 TokenRoutes progressive list 或图表可见性门控;
7. 迁移选定页面的共享 loading/error/empty/inline style,并做桌面/移动
回归;
8. 生成前后性能报告(chunk、请求并发、render/long-task 近似指标)。
风险文件:`src/web/api.ts`、`SearchModal.tsx`、`Dashboard.tsx`、
`ProxyLogs.tsx`、`ModelTester.tsx`、`Models.tsx`、相关页面测试。
### 3. `08-31-server-boundary-simplification`
依赖阶段 1 的架构守卫定义,但可独立实现和验证。
建议顺序:
1. 抽取 `accountVerificationDiagnostics`,以参数化测试锁定 shield、user-id、
bearer/cookie、endpoint pool、网络超时和失败 reason;
2. 先核对 `OneApiAdapter`/`VeloeraAdapter` 都直接继承 `BasePlatformAdapter` 的
事实;在中性策略 owner 和参数化测试证明兼容前,不改成
`StandardApiProviderAdapterBase` 子类。显式保留 quota divisor、header、
models fallback、check-in capability 和文案差异;
3. 将各 proxy path 的 `logProxy` 改为结构化 `recordProxyLog` owner;
4. 迁移 downstream policy、multipart helper 和 Accounts TokensPanel,删除
drift allowlist 中已解决的 tracked debt;
5. 运行 API/proxy/service/transformer architecture tests、server typecheck、
drift check 和相关构建。
风险文件:`src/server/routes/api/accounts.ts`、
`src/server/services/platforms/oneApi.ts`、`veloera.ts`、
`standardApiProvider.ts`、`src/server/proxy-core/surfaces/*`、
`src/server/routes/proxy/{completions,embeddings,images,search}.ts`、
`src/web/pages/Accounts.tsx` 和 `Tokens.tsx`。
## 共同验证门
每个子任务完成前按变更范围运行:
```bash
npm run typecheck:web
npm run typecheck:web:test
npm run typecheck:server
npm test -- <focused tests>
npm run repo:drift-check
git diff --check
npm run build:web
```
父任务最终再运行:
```bash
npm test
npm run typecheck
npm run repo:drift-check
npm run build
```
若全量测试遇到既有失败,保留完整命令、失败文件、退出码和是否与本任务
相关的判断,不通过删测试、放宽断言或隐藏 warning 来“修复”。
## 回滚点与停止条件
- 每个 wrapper、hook、服务抽取和页面迁移保持单一职责、独立 commit;
- 出现 API 响应变化、协议转换差异、焦点/滚动锁回归或跨域测速能力丢失时,
立即停止该子任务,保留失败证据并回到对应 facade,而不是继续扩大重构;
- 发现数据库 schema/migration 需求、外部实时服务缺失或需要删除用户能力时,
暂停并回到 PRD 评审,不自行扩大范围;
- 不使用 destructive git 命令,不覆盖用户已有 untracked/dirty 文件。
## 完成定义
- 三个子任务的验收标准均有测试或可重复的测量证据;
- 页面不直接 import 第三方 UI 原语,新增页面不绕过 `api.ts`(显式跨域/登录
例外有 owner 和 tracked debt);
- 已解决的 proxy-core/page boundary debt 从 drift allowlist 中删除;
- 目标页面的重复样式/状态/编排明显下降且没有能力回归;
- 全量检查、构建和文档/规范更新完成后,才可执行 Trellis finish/archive。
@@ -1,148 +0,0 @@
# 全项目简化与前端一致性
## 目标
在保留现有用户能力以及协议/数据库语义的前提下,降低 Metapi 全项目的维护
成本和行为漂移。项目应拥有一条容易理解的前端路径、一套可复用的视觉语言,
以及明确负责重复请求、状态和业务编排的 owner。所有管理页面的响应式行为、
加载/错误反馈、键盘行为和交互质量应保持一致。
## 已确认的仓库事实
- 项目是 TypeScript 单仓库,包含 Fastify 服务端、React 18/Vite 管理后台和
Electron 桌面封装;Web 路由已在 `src/web/App.tsx:22-39` 使用 lazy load。
- Web 应用在 `src/web/index.css` 中已有全局 token/class 体系(当前约 6,908
行、62 个 CSS variables),但多个页面仍携带大量 inline style。热点包括 `Settings.tsx`、
`ProxyLogs.tsx`、`Sites.tsx`、`Accounts.tsx`、`Models.tsx` 和 `ModelTester.tsx`。
- `vite.config.ts` 同时启用 React plugin 和 Tailwind Vite plugin,而生产界面
主要使用 `index.css` class 与 inline style;必须通过明确决策解决入口不一致,
不能让页面各自选择。
- `src/web/api.ts` 是认证请求/SSE 边界,拥有 timeout、session expiry、错误
提取和 proxy-test alias;少数页面仍直接调用浏览器 `fetch()`,需要先审计。
- 大型编排页面包括 `Settings.tsx`(2,647 行)、`ProxyLogs.tsx`(3,832 行)、
`Accounts.tsx`(3,499 行)和 `ModelTester.tsx`(3,401 行);`Sites.tsx` 为
2,426 行,`Dashboard.tsx` 为 1,710 行。现有领域子目录和响应式 primitive
证明增量抽取是项目接受的模式。
- 历史 single-source consolidation(`docs/plans/2026-03-23-single-source-consolidation-mega-plan.md`)
已覆盖协议归一化、route refresh、platform discovery、schema metadata、
responsive filter/action shell 以及部分 modal/drawer。新工作必须复用这些
owner,不得重建并行 helper。
- 当前工作树含用户拥有的未跟踪 Trellis/IDE/生成文件;它们不属于本任务范围,
必须原样保留。
- 兄弟项目 Iris 没有使用 Arco 或 Ant Design 全套视觉组件,而是项目组件 +
Radix Dialog/Tabs 原语、Tailwind token/utility、TanStack React Query、Zustand、
Lucide、Motion 和 dnd-kit;其 `OverlayDialog` 展示了“第三方行为隐藏在本地
wrapper 后面”的模式。
- 用户已选择 Iris-like 组件架构:Radix 原语只负责交互/无障碍,Metapi 保留
自己的靛蓝/灰色 token 和领域组件,路由页不得暴露或直接使用第三方组件 API。
Tailwind 是否保留由实际使用率和构建证据决定,不能因此混用多套页面体系。
- 规划阶段构建采样为 `2613 modules transformed`、约 `5.35s`,`vchart-vendor` 为
`2,154.02 kB`(gzip `586.81 kB`);最终复核为 `2614 modules`,这些数字来自
本机 dirty worktree,不能直接当作发布基线。
- `useAsyncResource`、`siteSpeedProbe`、`streamBatcher`、`components/ui/**` 和
中性 server helper 目前属于未提交工作树候选/WIP;是否纳入最终交付必须以
focused tests、typecheck、build、drift 和 review 证据为准。
## 需求
### R1. 建立可执行的简化契约
建立重复样式、页面 raw request、页面互引、异步状态转移和超大编排文件的基线
清单与 architecture check。新代码必须沿用 React、Vite、TypeScript、
`src/web/api.ts` 和全局 design token 路径;页面不得引入新的 UI framework、
CSS module 约定、样式 runtime 或本地 HTTP client,除非有明确记录的例外。
### R2. 统一前端视觉体系
把现有 CSS variable 和语义 class 作为唯一视觉语言,抽取通用的 page header、
section、field、loading/error/empty、action group 和 list/table state primitive。
优先迁移重复频率最高的 inline style,保留现有响应式断点和主题行为。新生产
UI 不得在已有 shared class/variant 可用时新增 one-off style object。
### R3. 收敛异步状态与请求 owner
提供小而类型安全的可取消加载 helper,负责 stale-result protection、loading/
error/empty 转移和 refresh invalidation;至少迁移一组高频页面。领域状态仍由
页面拥有,认证 HTTP/SSE 仍由 `src/web/api.ts` 负责,取消、timeout 和 partial
failure 的用户语义必须保持可区分。
### R4. 按职责拆分大型编排文件
把应用 shell 和最大页面热点拆成领域 hook、section 与 component,保持页面作为
编排面;modal/panel 家族放回现有领域目录,纯计算放入 helper。不得创建无主的
通用 `utils/` 垃圾场,也不得把业务规则搬入 route/page adapter。
### R5. 以证据驱动前端性能优化
保留路由级 code splitting,并针对列表、图表、搜索、轮询和 model tester 做
可测量优化:避免重复请求、取消过期工作、延迟非关键数据、稳定昂贵派生值,
减少无关 parent 更新导致的重渲染。记录前后 chunk/request/render 基线,目标
路径不得回归,并应有可重复的改善或明确的瓶颈转移证据。
### R6. 提升跨页面 UX 与可访问性一致性
用共享 primitive 统一 focus、keyboard、dialog/drawer 语义、移动 action/filter、
loading skeleton、empty state、可操作错误和翻译文本;保留 URL intent 与历史
行为。每个交互家族增加聚焦测试,并覆盖 stale request 和 cleanup。
### R7. 只合并有证据的服务端重复
审计 services、proxy-core、routes、transformers、contracts 和 platform adapter。
只有在存在清晰重复和 owner 时才合并,复用既有 workflow/failure vocabulary;
routes 保持 adapter、transformers 保持 protocol-pure。数据库变更不在本任务内,
除非单独发现并获批 schema 需求。
### R8. 留下可重复的维护守卫
记录选定的前端 stack 和抽取模式,在必要位置添加 architecture test,并纳入
repository drift gate,防止后续页面悄悄重新引入第二套技术或样式体系。
## 验收标准
- [ ] 检入一份 baseline/audit,记录重复样式、请求、状态和大文件热点,并把每个
选定改动链接到明确 owner。
- [ ] 前端有一条文档化的生产样式路径;新改页面不得引入独立 CSS/UI 技术或绕过
shared API boundary。未使用的 build plugin/dependency 必须删除,或有测试和
文档说明其保留理由。
- [ ] shared primitive 与 async request/state helper 有 unit 或 architecture test,
至少三个高频页面实际使用。
- [ ] 选定大型页面的编排边界变薄,路由、action、URL intent、响应式行为、翻译及
modal/drawer 无障碍保证均无损失。
- [ ] 性能测量证明 initial/route bundle、重复请求数和 stale 行为不回归;目标的
list/chart/model-test 路径有改善,或有可复现测量证明瓶颈在别处。
- [ ] 改过的异步路径会取消或 stale-guard 过期工作,恢复 timer/listener/body-scroll,
并提供可操作的 loading、empty 和 error feedback。
- [ ] 相关 focused tests、web/server type checks、`npm run repo:drift-check`、
`git diff --check` 和生产构建通过;既有无关失败必须带精确证据记录,不能隐藏。
- [ ] 维护者文档说明 canonical frontend stack、shared styles/hooks/components 的放置
位置,以及 architecture check 如何阻止页面自行发明实现。
## 范围内
- 一个带独立验收前端基础、前端性能/UX、服务端简化切片的 parent task;
- 在现有 React/Vite/Fastify 架构内的代码、测试、狭义文档和 architecture check 改动。
## 范围外
- 删除或重设计用户能力、改变 proxy protocol semantics、database schema/migrations,
或未经单独批准改变 public API contract;
- 替换 React/Vite、增加全局 state library,或一次重写所有历史页面;
- 清理无关的 untracked 文件、生成物、IDE 状态或既有用户改动。
## 关键决策
- **组件架构**:采用 Iris-like 的 Radix behavior primitives + 单一项目封装层;保留
并扩展已有 Metapi component,不为使用库而替换仍然正确的领域组件。
- **视觉兼容**:保留现有 Metapi CSS variables、主题、响应式断点和用户可见样式。
- **第三方 import 边界**:只有 `src/web/components/ui/**` 可以直接 import Radix;
routed pages/domain components 只能依赖项目 wrapper。
- **样式入口**:先依据 utility 使用率和 production build 证据决定 Tailwind 是否清理;
无论结果如何,不允许页面增加另一种 CSS runtime 或 convention。
当前没有待用户决策的产品问题;Tailwind 的处理结果属于实现前可验证的技术判断,
并会在 `design.md` 中记录证据。
## Trellis 文档语言
本任务后续新增或修改的 Trellis 文档默认使用简体中文。命令、路径、代码符号、
配置字段、协议名和第三方名称保持规范原文;已有英文历史内容不因该约定批量回译。
@@ -1,111 +0,0 @@
# Metapi 后端架构调查
## 查询元数据
- **查询**:调查 Fastify route、proxy-core、service、transformer、contract、database 和 platform adapter 的真实所有权,核对重复诊断、proxy log 及 drift debt。
- **范围**:`src/server/index.ts`、`src/server/routes/**`、`src/server/proxy-core/**`、`src/server/services/**`、`src/server/transformers/**`、`src/server/contracts/**`、`src/server/db/**`、相关测试与 `scripts/dev/repo-drift-check.ts`。
- **日期**:2026-08-31。
## 可复现命令
```bash
find src/server -maxdepth 2 -type d | sort
rg -n 'executeEndpointFlow|sharedSurface|readRuntimeResponseText' src/server/routes src/server/proxy-core
rg -n 'class .*Adapter|extends StandardApiProviderAdapterBase|extends BasePlatformAdapter' src/server/services/platforms
rg -n '500000|1000000|Veloera-User|New-API-User|User-id|available_model' src/server/services src/server/routes
rg -n 'diagnoseVerificationFailure|detectVerifyFailureReason|accountVerificationDiagnostics' src/server/routes/api/accounts.ts src/server/services
rg -n 'writeSurfaceProxyLog|logProxy|insertProxyLog|composeProxyLogMessage' src/server/routes/proxy src/server/proxy-core/surfaces
rg -n 'sqliteTable\\(|switchRuntimeDatabase|RuntimeDbDialect' src/server/db src/server/index.ts
npm run repo:drift-check
```
## 真实层次和启动编排
```text
src/server/routes/api/** 管理 API Fastify adapter
src/server/routes/proxy/** /v1 与供应商兼容 endpoint adapter/facade
src/server/proxy-core/** endpoint flow、surface、executor、provider、runtime
src/server/services/** 业务 workflow、平台 adapter、OAuth、日志、路由
src/server/transformers/** 协议纯净转换
src/server/contracts/** Zod 外部 payload parser
src/server/db/** schema、连接、迁移、runtime dialect、兼容处理
```
`src/server/index.ts:135-260` 在创建 Fastify 前确保 runtime DB、读取 settings、切换 dialect、执行 compatibility columns、backfill、route rebuild 和 OAuth registry;随后注册 auth hook、管理 API、proxy routes、静态 web 与 SPA fallback。它是启动/生命周期 owner,不是把 scheduler 或业务流程塞进 route 的理由。
## Route、core、service 和 transformer 规则
- route 文件可以注册 Fastify endpoint、读取 request context、调用 service/surface 和组装 HTTP response;不能拥有 retry policy、stream lifecycle、billing 或 persistence。
- endpoint fallback 必须通过 `src/server/proxy-core/orchestration/endpointFlow.ts` 的 `executeEndpointFlow()`;`src/server/routes/proxy/endpointFlow.ts` 只是兼容 re-export。
- `src/server/proxy-core/surfaces/sharedSurface.ts:239-300` 拥有共享 channel/session 记账、usage、billing、proxy log 和 failure toolkit;整包上游 body 读取通过 `src/server/proxy-core/executors/types.ts` 的 `readRuntimeResponseText()`。
- `src/server/transformers/` 不得导入 route、Fastify、OAuth service、token router 或 runtime dispatch;协议转换应可独立测试。
- 外部 request body 在 `src/server/contracts/` 用 Zod parser 归一化,route 只处理 parser 的结果和 HTTP status。
## Platform adapter 继承与差异(纠正旧任务文档)
真实继承关系:
```text
StandardApiProviderAdapterBase(standardApiProvider.ts)
├─ ClaudeAdapter
├─ GeminiAdapter
├─ OpenAiAdapter
├─ OrcaRouterAdapter
└─ CliProxyApiAdapter
BasePlatformAdapter(base.ts)
├─ OneApiAdapter(oneApi.ts)
│ └─ OneHubAdapter
│ └─ DoneHubAdapter
├─ VeloeraAdapter(veloera.ts)
├─ NewApiAdapter
├─ Sub2ApiAdapter
└─ 其他平台
```
`OneApiAdapter` 与 `VeloeraAdapter` 都直接继承 `BasePlatformAdapter`(分别见 `oneApi.ts:1,19`、`veloera.ts:1,8`),不是 `StandardApiProviderAdapterBase` 的子类。因此不能在设计或 PRD 中写“通过 StandardApiProviderAdapterBase 合并 OneApi/Veloera”,除非先完成独立的中性策略抽取并证明兼容。
已经由源码和测试锁定的差异:
| 平台 | 代码证据 | 语义 |
| --- | --- | --- |
| OneApi | `oneApi.ts:57-81` | check-in `/api/user/checkin`;quota/used/today 字段除以 `500000`;models 从 `/v1/models` 读取并写入 context-length cache |
| Veloera | `veloera.ts:23-58` | headers 可能同时带 `Veloera-User`、`New-API-User`、`User-id`;quota/used/today 除以 `1000000`;models 流程相似但 header contract 不同 |
| OneHub | `oneHub.ts:12-39` | 先 `/v1/models`,失败后 `/api/available_model`;user group 从 `/api/user_group_map` 读取 |
| DoneHub | `doneHub.ts:12-33`、测试 `doneHub.test.ts:78-105` | check-in 明确不支持;`quota` 是 remaining,`used_quota` 是 spent,total 为两者之和;继承 OneHub 的 model fallback |
可行方向是中性策略/config/helper + 参数化测试,不是强行改继承树。`StandardApiProviderAdapterBase` 当前只拥有 standard models URL、unsupported login/check-in/zero-balance defaults(`standardApiProvider.ts:37-97`)。
## 账号验证诊断
HEAD `accounts.ts` 同时有 `diagnoseVerificationFailure` 和 `detectVerifyFailureReason` 两套局部实现(`git show HEAD:src/server/routes/api/accounts.ts` 可复现,约 721-861、966-1055 行):两者都生成 Bearer/Cookie variants,访问 `/api/user/self`,解析 shield、`needs-user-id`/`invalid-user-id`,并使用 2,500ms 诊断 timeout;差异在 endpoint pool 和网络错误可见性。
当前工作树新增候选 `src/server/services/accountVerificationDiagnostics.ts`:
- `parseAccountVerificationFailureReason()` 负责纯文本/JSON/shield/user-id 分类(约 105-147 行);
- `buildAccountVerificationHeaderVariants()` 固定 Bearer/Cookie 顺序(约 153-190 行);
- `diagnoseAccountVerificationFailure()` 返回 `reason`、`sawResponse`、`sawNetworkError`、`status`、`endpoint`、`timedOut`、`retryable`(约 215-278 行)。
当前 route 已通过 `requireSiteApiBaseUrl(site)` 选择 endpoint,再注入 `withSiteRecordProxyRequestInit`;这是未提交 WIP,必须经过 accounts verify/rebind focused tests 和 architecture test 才能写成完成。
## Proxy log 所有权和剩余重复
- 共享 owner `writeSurfaceProxyLog()` 位于 `sharedSurface.ts:239-300`,统一 UTC 时间、`composeProxyLogMessage()`、`insertProxyLog()`、usageSource、billing、stream timing、client/session/trace metadata 和 best-effort warning。
- `sharedSurface.ts` 已被 chat、rerank 等 surface 使用,但 `routes/proxy/completions.ts`、`embeddings.ts`、`images.ts`、`search.ts` 以及 `proxy-core/surfaces/geminiSurface.ts` 仍有 local `logProxy`。迁移前必须逐个对照 `downstreamPath`、`upstreamPath`、`usageSource`、billing 默认值、stream timing、token 默认值、warning scope、status/retry 语义;不能只做名称替换。
- 当前候选中性 service/facade 为 `downstreamPolicyService.ts`、`multipart.ts`,并有 `shared-helper-boundaries.architecture.test.ts`;它们已让当前 drift checker 结果为 0/0,但仍未提交、未完成完整产品回归。
## Drift 与 page boundary
当前 checker 的 `HEAD` 归档有五条违规:四条 `proxy-core -> routes/proxy`(downstreamPolicy/multipart)和一条 `src/web/pages/Accounts.tsx -> ./Tokens.js`。当前工作树通过中性 service/facade 与 `pages/tokens/` 候选迁移暂时消除这些路径;历史 checker 曾把它们记成 tracked debt。任务材料必须同时写清 checker 版本和扫描对象。
## 数据库与测试边界
- `src/server/db/schema.ts` 有 27 个 `sqliteTable`,`drizzle/` 有 29 个 SQL 文件;`src/server/db/index.ts` 支持 SQLite/MySQL/PostgreSQL runtime switch。
- 本轮不改 schema/migration/generated artifacts;schema 需求必须单独建任务并同步 Drizzle schema、migration history、generated contract/parity 测试。
- 相关验证应覆盖 `src/server/routes/api/accounts*`、`src/server/services/platforms`、`src/server/proxy-core/surfaces`、`src/server/routes/proxy` 的 focused tests、`npm run typecheck:server`、`npm run build:server`、`npm run repo:drift-check` 和 `git diff --check`。Node/native ABI 不匹配时必须精确记录,不能隐藏失败。
## 反模式
- 不把 route 变成 workflow owner,不在 transformer 中引入 runtime/service,不在 proxy-core 中直接从 routes/proxy 取 helper。
- 不复制平台 quota/header/check-in 逻辑,也不把不兼容平台伪装成统一 capability。
- 不在 log migration 中丢失 path/usage/billing/trace 字段,不把 best-effort telemetry 失败变成主请求成功。
@@ -1,76 +0,0 @@
# Metapi 前端架构调查
## 查询元数据
- **查询**:确认 React/Vite 入口、页面边界、认证请求 owner、raw `fetch()` 例外、状态管理、样式入口、性能热点和当前候选 helper。
- **范围**:`src/web/main.tsx`、`src/web/App.tsx`、`src/web/api.ts`、`src/web/components/**`、`src/web/pages/**`、`src/web/index.css`、`vite.config.ts`、前端测试与 `package.json`。
- **日期**:2026-08-31。
## 可复现命令
```bash
nl -ba src/web/main.tsx
nl -ba src/web/App.tsx
rg -n '\\bfetch\\(' src/web --glob '*.ts' --glob '*.tsx'
rg -n 'useAsyncResource|siteSpeedProbe|streamBatcher' src/web --glob '*.ts' --glob '*.tsx'
rg -l 'style=\\{\\{' src/web/pages/*.tsx
wc -l src/web/index.css src/web/pages/*.tsx
npm run typecheck:web
npm run typecheck:web:test
npm run build:web
```
## 已确认的生产入口
- `src/web/main.tsx:1-13` 使用 `React.StrictMode` + `BrowserRouter` 挂载 `<App />`,并加载 `index.css`。
- `src/web/App.tsx:22-39` 通过 `lazy(() => import(...))` 注册 Dashboard、Sites、Accounts、Tokens、TokenRoutes、ProxyLogs、Settings、DownstreamKeys、Models、ModelTester 等路由;`App.tsx` 还拥有 shell、`I18nProvider`、`ToastProvider`、登录状态和导航。
- `package.json` 当前为 React `18.3.1`、Vite `6.4.2`、TypeScript `6.0.3`;UI 依赖包含 `@radix-ui/react-dialog` 和 `@visactor/react-vchart`。仓库没有 React Query、SWR、Redux 或 Zustand 依赖,server state 主要由 React state/Context + 手写 API method 管理。
## API 边界与 raw `fetch()` 分类
`src/web/api.ts` 是已认证管理 API 的默认 owner,负责 auth token、401/403 session 清理、timeout、外部 `AbortSignal`、body 生命周期、HTTP error extraction、JSON/SSE/binary response,以及 proxy-test alias(见 `api.ts:10-34`、`200-240`、`447-500`、`1912-1990`)。但“所有请求都经过 `api.ts`”不符合当前源码,必须保留以下有 owner 的例外:
| 文件/行 | 例外 | 为什么不能机械归入普通 wrapper | 必须保留的契约 |
| --- | --- | --- | --- |
| `src/web/App.tsx:150-181` | 登录 bootstrap | 此时 token 尚未持久化,登录页需要用用户输入的 token 探测公开 auth info | Authorization header、错误文本提取、登录失败翻译;后续管理请求才进入 `api.ts` |
| `src/web/pages/Sites.tsx:634-690` | `probe-stream` | 页面需要自己的 `AbortController` 和 `ReadableStream` 增量读取 | 每站取消、stream close、超时和逐项结果;不能把 no-cors/stream 误当普通 JSON |
| `src/web/api.ts:447-500` | client 内部 `fetch` | 这是 API boundary 的实现,不是页面绕过 | 统一 auth、timeout、错误和 body cleanup |
| `src/web/api.ts:1912-1990` | `testChatStream` 返回原始 `Response` | 调用方有意拥有 stream body 生命周期 | 明确 `wrapBody`/reader 责任,不能重复消费 body |
因此规范应写成“已认证管理 API 默认经过 `api.ts`;raw `fetch()` 仅在列出 owner、浏览器/跨域/stream 原因、auth、timeout、cancellation、测试和迁移边界时允许”,而不是禁止全部 raw `fetch()`。
## 状态与生命周期模式
- 当前 canonical state 仍是页面本地 `useState`、`useMemo`、`useRef` 和两个 Context;URL intent 由 React Router location/search 管理,安装级状态由 `authSession.ts`、`appLocalState.ts` 等小模块管理。
- `useIsMobile.ts`、`useAnimatedVisibility.ts`、`CenteredModal.tsx`、`MobileDrawer.tsx` 展示 listener/timer/animation/body-scroll cleanup 模式。
- `TokenRoutes.tsx` 已有 progressive chunk + `IntersectionObserver`;这是评估大列表优化的样例,不是所有列表都应机械窗口化。
- 过期请求必须由 cancelled flag、sequence ref 或 `AbortController` 保护;mutation 后刷新权威 snapshot,不能把派生 filter/sort 复制成第二份可变 server state。
## 样式与 bundle 事实
- `src/web/index.css` 当前实测 6,908 行、62 个 CSS variables,约 943 个以行首 `.` 统计的 class selector;页面大量组合语义 class 和少量 inline style。
- `vite.config.ts` 同时配置 React plugin 与 Tailwind Vite plugin,`index.css:1` 有 `@import "tailwindcss"`。
- 生产源码明确可见的 utility 至少有 `Settings.tsx` 与 `DownstreamKeys.tsx` 的 `h-4 w-4`;因此不能只凭“看起来很少”删除 Tailwind。应先完成 utility 使用率、生成 CSS 和 chunk 的可重复审计,再决定保留/清理。
- 规划阶段构建采样为 `2613 modules`、`5.35s`、`vchart-vendor 2,154.02 kB`(gzip `586.81 kB`);最终交付复核为 `2614 modules`、CSS `136.83 kB`(gzip `22.01 kB`)。图表 below-fold gate 必须以网络请求、布局稳定和图表出现后的功能测试为准。
## 当前工作树候选实现(未提交)
以下文件属于当前工作树 WIP,不等于 HEAD 或最终验收:
- `src/web/components/useAsyncResource.ts`:单活动请求、abort、递增 request id、mounted guard、silent refresh、dedupe、`AsyncErrorKind` 分类。当前工作树生产引用为 `Dashboard.tsx`、`Models.tsx`、`ProxyLogs.tsx`、`SearchModal.tsx` 四条路径;HEAD 没有该 helper。必须完成 focused test、typecheck、review 后才能计入验收。
- `src/web/pages/helpers/siteSpeedProbe.ts`:保留 `mode: "no-cors"`,每站 timeout/abort,默认并发 4,`probeSiteSpeeds()` 逐项回调。该模块是有意的跨域 browser-only 例外,不应伪装成 `api.ts` 管理请求。
- `src/web/pages/model-tester/streamBatcher.ts`:24ms 窗口,分别保留 delta/raw event 顺序,支持 `flush()`/`dispose()`;ModelTester 在停止、abort、完成和卸载路径显式 flush。必须用内容等价和 render/flush 计数验证,而不是只看编译。
- `SearchModal.tsx` 当前 WIP 已使用 `useAsyncResource`,通过 query ref + debounce timer 保护 q1→q2 stale 竞态;这覆盖了旧调查中“尚未迁移”的结论。
## 反模式与维护规则
- 顶层 page 不得互相 import;领域 modal/drawer/panel 放入 `pages/<domain>/`,共享 UI 放入 `components/` 或 `components/ui/`。
- 不新增第二套 CSS module/styled-components/runtime,不把业务规则搬入 UI wrapper,不为单页 server state 引入 global store。
- 新 effect 必须列出依赖并清理 timer/listener/reader/body lock;后台轮询要有单飞或 abort previous 与 `visibilitychange` 语义。
- 新 UI 使用真实 button/link、label、dialog role/aria 和 `t()`/`tr()`;测试应断言可见行为、键盘和 cleanup,不只断言 mock 调用。
## HEAD、工作树与限制
- HEAD 的 `SearchModal`、Dashboard、Models、ProxyLogs 使用各自的局部请求/状态逻辑;当前工作树已出现候选抽取,但产品改动还未完成全量回归。
- 本轮未运行真实浏览器 performance trace、生产网络或用户端 LCP/TTI;build 数字只作为本机可复现基线。
- Node 当前为 `v22.23.1`,而 package engine 为 `>=25.0.0`;完整前端/产品验收要在匹配运行时复核。
@@ -1,93 +0,0 @@
# Metapi 仓库基线调查
## 查询元数据
- **查询**:调查当前仓库的规模、运行时入口、依赖、测试门禁、数据库形态、Git 基线和 Trellis 任务状态。
- **范围**:`src/`、`drizzle/`、`scripts/`、`package.json`、`.trellis/`、`.codex/`;不修改产品代码,不把未提交产品 WIP 当作已验收结果。
- **日期**:2026-08-31(Asia/Shanghai)。
- **调查者**:`cita-777` session;当前分支为 `dev`。
## 可复现命令
以下命令在仓库根目录 `/Users/apple/code/metapi` 执行:
```bash
python3 ./.trellis/scripts/get_context.py
python3 ./.trellis/scripts/get_context.py --mode phase
python3 ./.trellis/scripts/get_context.py --mode packages
git status --short
git log --oneline -5
npm run repo:drift-check
npm run typecheck:web
npm run build:web
git diff --check
```
规模统计使用 `Path.rglob('*.ts'/'*.tsx')` 计算文件数和物理行数;CSS、页面热点和 `sqliteTable(` 使用 `rg`/`wc` 复核。构建数字来自本轮实际的 `vite build` 输出,不沿用旧任务文档中的估算。
## Git 与 Trellis 状态
- `HEAD`:`f4114ada6d797e668c83c1b3f68ca80f8c897863`,提交说明为 `fix: tolerate corrupted site announcement cache rows`。
- 当前工作树有约 71 个未提交路径,包含用户产品 WIP、`.agents/`、`.codex/`、`.trellis/`、IDE 状态和生成文件。它们均视为用户已有内容;本调查不清理、不覆盖、不提交。
- `.trellis/scripts/get_context.py` 报告为 single-repo,spec layers 为 `backend`、`frontend`;当前 session 没有自己的 `CURRENT TASK` 指针,但存在四个活动任务:一个 parent planning、两个 child in_progress、一个 child planning。
- 不要在本 session 为了制造指针运行 `task.py start`;其他 session 正在处理两个 child task,活动任务指针是 session-specific 的。
## 规模快照
本轮工作树实测如下(生成文件是否存在会影响少量行数):
| 路径 | TypeScript/TSX 文件 | 行数 |
| --- | ---: | ---: |
| `src/server` | 648 | 187,574 |
| `src/web` | 280 | 77,035 |
| `src/desktop` | 7 | 912 |
| `src/shared` | 17 | 796 |
| `src/server/routes/api` | 79 | 34,230 |
| `src/server/routes/proxy` | 53 | 23,647 |
| `src/server/proxy-core` | 70 | 15,159 |
| `src/server/services` | 251 | 66,102 |
| `src/server/transformers` | 119 | 34,441 |
| `src/server/contracts` | 7 | 1,325 |
| `src/server/db` | 38 | 8,401 |
| `src/web/components` | 62 | 6,621 |
| `src/web/pages` | 193 | 64,697 |
页面热点(`wc -l`)为:`ProxyLogs.tsx` 3,832、`Accounts.tsx` 3,499、`ModelTester.tsx` 3,401、`Settings.tsx` 2,647、`Sites.tsx` 2,426、`TokenRoutes.tsx` 2,041、`Dashboard.tsx` 1,710、`DownstreamKeys.tsx` 1,415、`Models.tsx` 1,124、`OAuthManagement.tsx` 2,601。`style={{` 的文件级热点为 `Settings.tsx` 192、`ProxyLogs.tsx` 185、`Sites.tsx` 134、`Models.tsx` 119、`Accounts.tsx` 109、`ModelTester.tsx` 108、`DownstreamKeys.tsx` 82、`Dashboard.tsx` 64。
## 依赖和构建基线
- `package.json` 声明 Node `>=25.0.0`;本次本地命令使用 Node `v22.23.1`,因此完整产品验收不能只凭本地通过推断。
- 关键版本:React `18.3.1`、Vite `6.4.2`、Fastify `5.8.4`、Drizzle ORM `0.45.2`、`@radix-ui/react-dialog` `1.1.23`、`@visactor/react-vchart` `2.0.22`、Tailwind CSS `4.0.0`。
- 规划阶段的 `npm run build:web` 采样为:`2613 modules transformed`,约 `5.35s` 完成;CSS 为 `136.78 kB`(gzip `22.00 kB`),`vchart-vendor` 为 `2,154.02 kB`(gzip `586.81 kB`)。较大的路由 chunk 为 `TokenRoutes` `176.61 kB`、`Settings` `105.96 kB`、`ModelTester` `88.98 kB`、`DownstreamKeys` `65.48 kB`、`Accounts` `60.73 kB`、`Sites` `59.67 kB`、`ProxyLogs` `58.56 kB`;最终交付复核见 `docs/frontend-architecture.md` 与任务审计。
- 旧 `design.md` 记录的 `2548 modules/5.30s/2,145.97 kB` 已过时;后续报告应标明采样时间和工作树状态。
- `npm run repo:drift-check` 当前工作树实测为 `Violations: 0`、`Tracked debt: 0`。这只证明当前 checker 对当前文件集的结果,不证明未提交 WIP 已通过产品回归。
## Drift 基线口径
为避免把两个时期混为一谈,保留三种口径:
1. **当前工作树 + 当前 checker**:`0 violations / 0 tracked debt`。
2. **当前 checker 扫描 `HEAD` 归档**:`5 violations / 0 tracked debt`,路径为 `chatSurface.ts -> routes/proxy/downstreamPolicy.js`、`filesSurface.ts -> routes/proxy/multipart.js`、`geminiSurface.ts -> routes/proxy/downstreamPolicy.js`、`openAiResponsesSurface.ts -> routes/proxy/downstreamPolicy.js`、`Accounts.tsx -> ./Tokens.js`。
3. **历史 HEAD checker + 历史 allowlist**:早期任务材料把这五项记成 tracked debt。若要复现历史数字,必须使用 `HEAD` 版本的 `scripts/dev/repo-drift-check.ts`;不能用当前已删除 allowlist 的 checker 反推历史 debt 数字。
临时 HEAD 归档位于 `/tmp/metapi-head-JBtZz7`,仅用于本轮只读比较,不能写入任务 manifest,也不能视为发布产物。
## 数据库快照
- `src/server/db/schema.ts` 当前有 27 个 `sqliteTable` 声明;`drizzle/` 有 29 个 SQL migration 文件。
- migration 文件名存在两个 `0027`:`0027_site_custom_headers_override_request_headers.sql` 与 `0027_site_max_concurrency.sql`。这属于需要在 schema 任务中专门处理的事实,本轮不改名、不重排。
- `src/server/db/index.ts` 暴露 `RuntimeDbDialect = 'sqlite' | 'mysql' | 'postgres'`、`switchRuntimeDatabase()`、transaction wrapper 和 compatibility-column bootstrap;`src/server/index.ts:135-193` 在读取设置后执行 runtime DB 切换、兼容列、默认数据、OAuth backfill 和 route rebuild。
- 本轮没有修改 schema、migration 或 `src/server/db/generated/`;任何必须改 schema 的发现都应另建任务。
## 质量证据与限制
- `npm run typecheck:web` 和 `npm run build:web` 已在当前工作树执行并通过;构建前自动运行了 `desktop:icons`。
- `npm run repo:drift-check` 与 `git diff --check` 是文档/边界工作的最低门禁。
- 完整 `npm test` 不能在 Node 22 + 当前本机 native dependency 组合下直接当作产品验收;历史记录显示 `better-sqlite3` 可能出现 `NODE_MODULE_VERSION` 不匹配。需要在声明的 Node 版本下重建 native module 后再判断全量结果。
- 本轮没有真实生产网络、浏览器性能 trace、MySQL/PostgreSQL live service 或部署证据;chunk 数字是本地构建测量,不能替代线上 LCP/TTI 或服务健康证明。
## 对后续规范的约束
- 研究文档必须区分 `HEAD` 已确认事实、当前工作树事实、未提交候选实现和最终验收状态。
- 任何“已完成迁移”“debt 已清零”“性能已改善”的表述都必须附带对应测试、构建、drift 或运行时证据;仅有 source diff 不足以升级状态。
- 文档中的命令、路径、函数名、配置字段、协议名和 `status` 值保持原文;解释性正文使用简体中文。
@@ -1,95 +0,0 @@
# Metapi Trellis 运行时调查
## 查询元数据
- **查询**:调查本地 Trellis 的 workflow parser、status/breadcrumb 映射、Codex dispatch、按 session 隔离的活动任务、任务生命周期、JSONL manifest、workspace journal 和 Codex hooks 输入输出。
- **范围**:`.trellis/workflow.md`、`.trellis/scripts/common/active_task.py`、`task_store.py`、`task_context.py`、`task.py`、`session_context.py`、`add_session.py`、`developer.py`、`.codex/hooks/*.py`、`.codex/hooks.json`、`.trellis/config.yaml`。
- **日期**:2026-08-31。
## 可复现命令
```bash
python3 ./.trellis/scripts/get_context.py
python3 ./.trellis/scripts/get_context.py --mode phase --platform codex
python3 ./.trellis/scripts/task.py --help
python3 ./.trellis/scripts/task.py current --source
rg -n '\\[workflow-state:' .trellis/workflow.md
nl -ba .codex/hooks/inject-workflow-state.py
nl -ba .codex/hooks/session-start.py
nl -ba .codex/hooks/inject-subagent-context.py
python3 -m compileall -q .trellis/scripts .codex/hooks
```
## Workflow parser/tag 契约
- `.trellis/workflow.md` 是自然语言工作流和 breadcrumb body 的单一事实来源;`.codex/hooks/inject-workflow-state.py:189-219` 用正则解析 `[workflow-state:STATUS]`,`STATUS` 只能是 `[A-Za-z0-9_-]+`,开闭标签必须使用同一个值。
- `build_breadcrumb()`(约 `346-367` 行)按 `breadcrumb_key` 查 body;缺少标签或 workflow 文件时退化为通用提示。该 fallback 必须是简体中文,例如“请查阅 `.trellis/workflow.md` 确认当前步骤”,但不能改变 `<workflow-state>` 包装、status header 或解析正则。
- 初始调查时文件只有 `no_task` 标签,活动任务状态无法获得对应详细 body;随后已在本地 `workflow.md` 补齐 `planning`、`planning-inline`、`in_progress`、`in_progress-inline`、`completed` block。当前状态应以 `rg -n '\[workflow-state:' .trellis/workflow.md` 的实时结果为准。
- status 到 breadcrumb key 的约定:非 Codex 使用 `status`;Codex `dispatch_mode=auto` 使用普通 `status`,`inline` 使用 `${status}-inline`;旧值 `sub-agent` 归一化为 `auto`;非法显式值归一化为 `inline`(`inject-workflow-state.py:273-343`)。
- `no_task` 是没有活动任务的伪状态,不来自 `task.json.status`;stale pointer 会形成 `stale_<source_type>`,因此标签正则必须允许下划线和连字符。
## Codex dispatch 与上下文顺序
- `.trellis/config.yaml` 的 `codex.dispatch_mode` 默认 `auto`;`inject-workflow-state.py:296-323` 生成 `<codex-mode>` banner,说明 `auto` 优先 dispatch Trellis sub-agent,主 session 仍负责协调、澄清、更新规范、commit 和 finish;`inline` 由主 session 直接实现/检查。
- `.codex/hooks/inject-subagent-context.py` 处理 native `SubagentStart` 与平台 Task 输入;实现/检查 agent 读取顺序为 JSONL manifest → `prd.md` → `design.md`(若有)→ `implement.md`(若有),并按角色加载 spec/research。Codex 不应把继承 parent transcript 当作唯一上下文来源。
- Trellis 本身不注册 MCP server;`.codex/config.toml` 和 hooks 只负责本地 context injection/agent wiring。不要把全局 host 的 MCP 警告归因于 Trellis runtime。
## 按 session 隔离的活动任务(Session-specific active task)
- `.trellis/scripts/common/active_task.py:1-7` 明确说明 active task 是按 AI session/window 存储的指针,目录为 `.trellis/.runtime/sessions/`;没有稳定 session key 就没有可持久化的 active task。
- context key 优先来自 `TRELLIS_CONTEXT_ID`,也可由平台已验证的 session/conversation/transcript 环境变量解析(约 `37-149`、`294-322` 行)。脚本会 sanitize key 并限制长度,避免把原始路径直接当文件名。
- `task.py create` 在写入 task 目录后 best-effort 调用 `set_active_task`;有 session identity 时只影响当前 session,缺失时仍创建 `status=planning` 任务但不写全局指针。
- `task.py start`(`task.py:73-140`)设置当前 session 指针并把 `planning` 改为 `in_progress`;无 session identity 时进入 degraded mode,只改变 `task.json.status` 并提示当前 session 没有持久指针。`finish` 清除当前 session 文件,不改变 task status。
- 指针指向不存在目录或无法读取 task 时应显示 stale/unknown,不能静默选择另一个 task;修复 stale pointer 应使用 `task.py finish` 或重新 `start`,不要手改 `.runtime/sessions/`。
## 任务生命周期与机器字段(Task lifecycle)
| 命令 | 真实副作用 | 保持不变的字段/语义 |
| --- | --- | --- |
| `task.py create` | 创建 `.trellis/tasks/MM-DD-slug/`、`task.json`、默认 `prd.md`;按平台条件 seed JSONL;可建立 parent/child;best-effort 激活 session | `status=planning`、`id/name/title/parent/children/meta` 等 JSON key |
| `task.py start <dir>` | 设置 session pointer;将 `planning` 写为 `in_progress`;运行 `after_start` hook | `status` 值、路径解析和 hook 输入 `TASK_JSON_PATH` |
| `task.py finish` | 清除当前 session pointer;运行 `after_finish` hook(若 task 仍可解析) | 不把 finish 当作 completed;不自动改 task status |
| `task.py archive <dir>` | 将 task 标记 `completed`、写 `completedAt`、清除仍指向它的 session、移动到 `archive/YYYY-MM/`;按配置可 auto-commit;运行 `after_archive` hook | `completed` 是 archive writer 写入的 status;目录移动和 parent/child 处理顺序 |
`task.py create` 的 `--no-start` 只跳过 session 激活,不跳过目录和 status 写入。归档前的 branch stale 只 warning,不阻塞;`session_auto_commit: false` 时脚本不能触碰 Git。
## JSONL seed/manifest 契约
- `task_store.py:117-174` 只在检测到 sub-agent-capable platform(Codex `dispatch_mode=auto` 或配置目录)时创建 `implement.jsonl`、`check.jsonl`。seed 是一行 `{"_example": "..."}`,没有 `file` 字段,因此消费者会跳过它。
- `_SEED_EXAMPLE` 的自然语言应使用简体中文,但 JSON key `_example`、`file`、`type`、`reason` 和路径保持原文。seed 不是 curated entry;规划阶段必须补至少一个真实 `{"file":"...","reason":"..."}`。
- `task_context.py:226-314` 对每行 JSONL 做解析;空行/seed 行跳过,非法 JSON、缺失文件或目录是 error;代码文件路径只产生 hygiene warning,spec/research 之外的产品代码不应预注册到 manifest;超出 context byte cap 只 warning。
- `list-context` 展示 `implement.jsonl`/`check.jsonl` 的真实条目和 `reason`;`validate` 的输出是诊断接口,中文化不能改变退出码或 JSONL 解析。
本任务父 manifest 必须引用四份 research 与相关 spec;不得引用 `src/**` 产品代码作为上下文条目。子任务 manifest 可以按实际领域追加 research,但理由使用简体中文。
## 工作区与 journal 契约(Workspace/journal contract)
- `.trellis/workspace/<developer>/journal-N.md` 是 append-only session log;`add_session.py` 在约 2000 行时创建下一个文件,并更新 `index.md` 的 `@@@auto:*` managed regions。
- 旧 `index.md` 可能仍有英文列名;`add_session.py:94-105` 同时识别 `Total Sessions` 与 `会话总数`,因此中文化生成模板时必须保留旧英文读取兼容。
- journal rotation、session number、branch resolution、auto-commit scope 和 `.gitattributes` 合并行为是机器/协作语义;只翻译标题、提示和自然语言,不改 marker、列数或路径。
## Hook 输入输出契约
- `.codex/hooks.json` 将 `inject-workflow-state.py` 绑定到每轮 UserPromptSubmit 等价事件,将 `inject-subagent-context.py` 绑定到 `SubagentStart`;`session-start.py` 输出 Codex hook JSON。
- hook 从 stdin 读取 JSON;缺失、空白、malformed 或非 object 输入先安全归一化为空 dict,不能抛出未处理异常,也不能阻塞等待未关闭 stdin。在 Trellis 目录中,空 dict 会按“无活动任务”流程输出 breadcrumb;非 Trellis 目录则无输出。输出必须仍是合法 JSON,`ensure_ascii=False` 允许中文。
- `session-start.py` 输出 `{suppressOutput, systemMessage, hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:"..."}}`;`inject-workflow-state.py` 输出 `hookSpecificOutput.additionalContext` 或 Kiro 所需纯文本分支;`inject-subagent-context.py` 保持 role、task path、curated context 和平台输入 schema。
- 用户可见 fallback、status 摘要、`<trellis-bootstrap>`、`<ready>`、agent prompt 自然语言默认使用简体中文;`hookEventName`、XML-like tag、JSON key、status 值、命令和路径保持原文。
- skip 条件(`TRELLIS_HOOKS=0`、`TRELLIS_DISABLE_HOOKS=1`、`no-trellis` keyword、非 Trellis 目录)必须保持静默退出或既有退出码;中文化不能让 hook 在这些分支输出额外文本。
## 运行时反模式与验证
- 不在 hook 中另造与 workflow.md 冲突的 fallback status 字典;parser 只读取 tag body,缺失时报告可修复的中文 generic hint。
- 不把 Codex `dispatch_mode` 写成新的 status;dispatch mode 与 workflow state 是两个独立 contract。
- 不用全局 `.current-task` 替代 session pointer,不直接编辑 `.runtime/sessions/`,不把 `finish` 当作 archive。
- 修改 workflow tag 后运行:
```bash
python3 ./.trellis/scripts/get_context.py --mode phase --platform codex
python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-31-project-wide-simplification
python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-31-frontend-ui-foundation
python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-31-frontend-performance-ux
python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-31-server-boundary-simplification
python3 -m compileall -q .trellis/scripts .codex/hooks
```
另加静态审计:逐行解析所有 JSONL、检查 spec/research Markdown 链接和路径、搜索 placeholder/TODO、检查上游模板词是否误入 Metapi 规范,以及将 hook stdout 送入 `json.loads`。本轮不把已安装的上游英文 skill/template 批量翻译;只有实际修改的本地 Trellis 文档和 hook 文案按中文约定更新。
@@ -1,33 +0,0 @@
{
"id": "project-wide-simplification",
"name": "project-wide-simplification",
"title": "全项目简化与前端一致性",
"description": "采用 Iris-like 的 Radix 原语 + 项目封装层,分阶段收敛前端 UI、请求性能/UX 与服务端边界重复,保持现有能力和协议/数据库语义不变。",
"status": "planning",
"dev_type": null,
"scope": "web-server",
"package": null,
"priority": "P2",
"creator": "cita-777",
"assignee": "cita-777",
"createdAt": "2026-08-31",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [
"08-31-frontend-ui-foundation",
"08-31-frontend-performance-ux",
"08-31-server-boundary-simplification"
],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {
"architecture": "iris-like-radix-wrappers",
"planning_baseline": "2026-08-31"
}
}
@@ -1,4 +0,0 @@
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"检查 route/service/transformer 边界"}
{"file":".trellis/spec/backend/error-handling.md","reason":"检查 status、timeout 和 best-effort"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"检查协议和数据库行为不变"}
{"file":".trellis/spec/guides/documentation-language.md","reason":"检查新增任务说明是否遵循中文文档约定"}
@@ -1,51 +0,0 @@
# 服务端边界简化:设计
## 账号验证诊断
service 接受规范化的候选 base URL、凭据/header 变体和 timeout 选项,返回
结构化诊断结果(reason、是否收到响应、HTTP status、是否需要 user id、可重试
信息)。route 只负责选择 endpoint pool 和把结果映射为现有 HTTP 响应。必须
保留 `sawNetworkError && !sawResponse` 等现有语义,不能用 catch-all 把 timeout
变成“未知”。
## OneApi/Veloera 共同流程
两者都直接继承 `BasePlatformAdapter`;`StandardApiProviderAdapterBase` 属于
另一条 provider 继承线,不能直接作为共同基类。若重复流程确实可收敛,应在
中性 service/策略模块中定义可选策略对象:
```ts
type StandardApiAdapterOptions = {
buildAuthHeaders?: (credential: unknown) => Record<string, string>;
quotaDivisor?: number;
checkin?: { successMessage?: string; unsupported?: boolean };
};
```
OneApi/Veloera 的 adapter 或 service 只声明可验证的差异;平台探测、特殊 header、
quota divisor、models fallback、check-in capability 和错误文案仍由平台模块拥有,
避免中性抽象吞掉平台特性。必须先补参数化测试,再决定是否落地抽取。
## Proxy log 所有权
使用命名字段的结构化输入,不再传位置参数或 route-local `any`:
```ts
type ProxyLogRecordInput = {
request: FastifyRequest;
path: string;
usage?: ProxyUsage;
usageSource?: string;
billing?: ProxyBillingDetails;
// downstream/session/trace fields remain explicit
};
```
owner 负责 UTC 时间、消息组合、insert 和 best-effort warning;调用方保留业务
上决定的默认值和是否记录的条件。
## 边界债务(Boundary debt)
把 downstream policy 的 request/auth 适配与纯策略计算分离;把 multipart parser
放到 proxy-core/shared 或 service 中;把 TokensPanel 放进 `pages/tokens/`
或中性组件目录。更新 drift allowlist 只在真实迁移完成后进行。
@@ -1,6 +0,0 @@
{"file":".trellis/spec/backend/directory-structure.md","reason":"service/route/proxy 所有权"}
{"file":".trellis/spec/backend/error-handling.md","reason":"诊断和日志错误语义"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"架构测试和类型检查"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"服务端重复收敛"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"路由、服务和代理契约"}
{"file":".trellis/spec/guides/documentation-language.md","reason":"保持 Trellis 实施文档和清单使用简体中文"}
@@ -1,23 +0,0 @@
# 服务端边界简化:执行清单
1. 为 accounts 诊断抽取纯 helper/service 和 table-driven tests;先保持 route
输出不变,再删除重复实现。
2. 核对 `OneApiAdapter`/`VeloeraAdapter` 的真实继承关系和平台差异;先以中性
策略与参数化 adapter 测试验证,再决定是否抽取共同流程,不强行改为
`StandardApiProviderAdapterBase` 子类。
3. 统一 shared `recordProxyLog`,逐个迁移 Gemini/completions/embeddings/images/
search,运行每个 endpoint 的 usage/billing/stream 回归。
4. 迁移 downstream policy、multipart helper 和 TokensPanel;删除对应 drift
allowlist,扩展 architecture test 防止回归。
5. 运行:
```bash
npm test -- src/server/routes/api/accounts* src/server/services/platforms src/server/proxy-core/surfaces src/server/routes/proxy
npm run typecheck:server
npm run repo:drift-check
npm run build:server
git diff --check
```
停止条件:协议 payload、错误 status、网络超时分类、proxy log 字段或数据库
行为变化时,恢复 facade/旧 owner 并回到父任务评审;不修改 schema。
@@ -1,60 +0,0 @@
# 服务端边界简化
## 目标
在既有 single-source consolidation 之上,消除仍然存在且有证据的服务端重复
编排和 proxy-core 边界债务;保持协议转换、路由策略、权限、数据库和错误语义
不变,让 route 只做适配、service/workflow 才拥有业务流程。
## 已确认事实
- `accounts.ts` 的 `diagnoseVerificationFailure` 与 `detectVerifyFailureReason`
两段逻辑都解析 shield/user-id、构造 Bearer/Cookie 变体并请求
`/api/user/self`,只在 endpoint pool 和网络错误语义上有差异。
- `oneApi.ts` 与 `veloera.ts` 重复 check-in、balance、models/context cache;
差异主要是 header、quota divisor 和文案。
- Gemini、completions、embeddings、images、search 中重复
`formatUtcSqlDateTime → composeProxyLogMessage → insertProxyLog → catch warn`;
`sharedSurface.ts` 已有可复用 owner 但并非所有路径使用。
- 当前工作树运行 `repo:drift-check` 为 `0 violations / 0 tracked debt`;同一
checker 只读扫描 `HEAD` 为 `5 violations / 0 tracked debt`(4 条
proxy-core → routes/proxy import,1 条 Accounts → Tokens page import)。任务
记录必须标明扫描对象,不能把历史违规写成当前 debt。
## 需求
1. 抽取中性 `accountVerificationDiagnostics`(或等价 owner),统一两套验证
诊断,保留 endpoint pool、`requireSiteApiBaseUrl`、network timeout、shield、
user-id reason 和错误可见性。
2. 先核对平台继承关系:`OneApiAdapter` 与 `VeloeraAdapter` 当前都直接继承
`BasePlatformAdapter`,不是 `StandardApiProviderAdapterBase` 的子类。只有在
中性策略 owner 和参数化测试证明兼容后,才考虑抽取共同流程;不得为了“统一”
强行改变继承树。header、quota divisor、check-in、models/context cache 的
差异必须显式保留。
3. 统一结构化 `recordProxyLog` helper,迁移 proxy log 重复路径,保留每个
endpoint 的 path、usageSource、billing/token 默认值和 warning scope。
4. 将 `downstreamPolicy`、multipart helper 等从 routes/proxy 移到正确的中性
owner;迁移 Accounts TokensPanel 到 domain 子目录,删除已解决的 tracked debt。
5. 每个抽象都有参数化/架构回归测试;不增加 `any`、不改变 HTTP 状态或协议
payload,不把 route 变成新的业务 owner。
## 验收标准
- [ ] accounts 验证诊断单一 owner 覆盖 shield、needs/invalid user-id、
Bearer/Cookie、endpoint pool、超时和 network/no-response 语义;原有
accounts verify/rebind 测试通过。
- [ ] OneApi/Veloera 适配器参数化测试证明 header、配额换算、模型 context cache、
check-in 成功/失败语义一致。
- [ ] 各 proxy log 路径通过结构化 owner 写入一次,字段、usageSource、billing、
client/session/trace 元数据与现有契约一致,insert 失败仍按 best-effort
规则处理。
- [ ] proxy-core 不再新增或保留已解决的 routes/proxy import,Accounts 不再
直接 import 顶层 Tokens 页面;drift check tracked debt 数量下降。
- [ ] server typecheck、相关服务/route/proxy tests、drift check、build 和
diff check 通过;schema/migration 未被改动。
## 范围外
- 改变数据库 schema/migration/generated artifacts;
- 改变代理协议转换、路由选择或外部 API 合约;
- 无证据的“大一统 service”重写或删除兼容分支。
@@ -1,26 +0,0 @@
{
"id": "server-boundary-simplification",
"name": "server-boundary-simplification",
"title": "服务端边界简化",
"description": "消除已证实的 service/route/proxy 重复并用聚焦测试清理边界债务。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "cita-777",
"assignee": "cita-777",
"createdAt": "2026-08-31",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-31-project-wide-simplification",
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -1,149 +0,0 @@
# Bootstrap Task: Fill Project Development Guidelines
**You (the AI) are running this task. The developer does not read this file.**
The developer just ran `trellis init` on this project for the first time.
`.trellis/` now exists with empty spec scaffolding, and this bootstrap task
exists under `.trellis/tasks/`. When they want to work on it, they should start
this task from a session that provides Trellis session identity.
**Your job**: help them populate `.trellis/spec/` with the team's real
coding conventions. Every future AI session — this project's
`trellis-implement` and `trellis-check` sub-agents — auto-loads spec files
listed in per-task jsonl manifests. Empty spec = sub-agents write generic
code. Real spec = sub-agents match the team's actual patterns.
Don't dump instructions. Open with a short greeting, figure out if the repo
has any existing convention docs (CLAUDE.md, .cursorrules, etc.), and drive
the rest conversationally.
---
## Status (update the checkboxes as you complete each item)
- [x] Fill backend guidelines
- [x] Fill frontend guidelines
- [x] Add code examples
## Completion Evidence
- Backend and frontend indexes now describe their pre-development and quality
checklists and link to the maintained guideline files.
- Guideline files cite real Metapi modules, tests, commands, and architectural
boundaries; template-only placeholder text was removed.
- The generated `.gitattributes` reference now points at the repository's actual
backend specification path.
- No product source files were changed as part of this bootstrap.
---
## Spec files to populate
### Backend guidelines
| File | What to document |
|------|------------------|
| `.trellis/spec/backend/directory-structure.md` | Where different file types go (routes, services, utils) |
| `.trellis/spec/backend/database-guidelines.md` | ORM, migrations, query patterns, naming conventions |
| `.trellis/spec/backend/error-handling.md` | How errors are caught, logged, and returned |
| `.trellis/spec/backend/logging-guidelines.md` | Log levels, format, what to log |
| `.trellis/spec/backend/quality-guidelines.md` | Code review standards, testing requirements |
### Frontend guidelines
| File | What to document |
|------|------------------|
| `.trellis/spec/frontend/directory-structure.md` | Component/page/hook organization |
| `.trellis/spec/frontend/component-guidelines.md` | Component patterns, props conventions |
| `.trellis/spec/frontend/hook-guidelines.md` | Custom hook naming, patterns |
| `.trellis/spec/frontend/state-management.md` | State library, patterns, what goes where |
| `.trellis/spec/frontend/type-safety.md` | TypeScript conventions, type organization |
| `.trellis/spec/frontend/quality-guidelines.md` | Linting, testing, accessibility |
### Thinking guides (already populated)
`.trellis/spec/guides/` contains general thinking guides pre-filled with
best practices. Customize only if something clearly doesn't fit this project.
---
## How to fill the spec
### Step 1: Import from existing convention files first (preferred)
Search the repo for existing convention docs. If any exist, read them and
extract the relevant rules into the matching `.trellis/spec/` files —
usually much faster than documenting from scratch.
| File / Directory | Tool |
|------|------|
| `CLAUDE.md` / `CLAUDE.local.md` | Claude Code |
| `AGENTS.md` | Codex / Claude Code / agent-compatible tools |
| `.cursorrules` | Cursor |
| `.cursor/rules/*.mdc` | Cursor (rules directory) |
| `.windsurfrules` | Windsurf |
| `.clinerules` | Cline |
| `.roomodes` | Roo Code |
| `.github/copilot-instructions.md` | GitHub Copilot |
| `.vscode/settings.json` → `github.copilot.chat.codeGeneration.instructions` | VS Code Copilot |
| `CONVENTIONS.md` / `.aider.conf.yml` | aider |
| `CONTRIBUTING.md` | General project conventions |
| `.editorconfig` | Editor formatting rules |
### Step 2: Analyze the codebase for anything not covered by existing docs
Scan real code to discover patterns. Before writing each spec file:
- Find 2-3 real examples of each pattern in the codebase.
- Reference real file paths (not hypothetical ones).
- Document anti-patterns the team clearly avoids.
### Step 3: Document reality, not ideals
**Critical**: write what the code *actually does*, not what it should do.
Sub-agents match the spec, so aspirational patterns that don't exist in the
codebase will cause sub-agents to write code that looks out of place.
If the team has known tech debt, document the current state — improvement
is a separate conversation, not a bootstrap concern.
---
## Quick explainer of the runtime (share when they ask "why do we need spec at all")
- Every AI coding task spawns two sub-agents: `trellis-implement` (writes
code) and `trellis-check` (verifies quality).
- Each task has `implement.jsonl` / `check.jsonl` manifests listing which
spec files to load.
- The platform hook auto-injects those spec files + the task's `prd.md`
into every sub-agent prompt, so the sub-agent codes/reviews per team
conventions without anyone pasting them manually.
- Source of truth: `.trellis/spec/`. That's why filling it well now pays
off forever.
---
## Completion
When the developer confirms the checklist items above are done with real
examples (not placeholders), guide them to run:
```bash
python3 ./.trellis/scripts/task.py finish
python3 ./.trellis/scripts/task.py archive 00-bootstrap-guidelines
```
After archive, every new developer who joins this project will get a
`00-join-<slug>` onboarding task instead of this bootstrap task.
---
## Suggested opening line
"Welcome to Trellis! Your init just set me up to help you fill the project
spec — a one-time setup so every future AI session follows the team's
conventions instead of writing generic code. Before we start, do you have
any existing convention docs (CLAUDE.md, .cursorrules, CONTRIBUTING.md,
etc.) I can pull from, or should I scan the codebase from scratch?"
@@ -1,29 +0,0 @@
{
"id": "00-bootstrap-guidelines",
"name": "00-bootstrap-guidelines",
"title": "Bootstrap Guidelines",
"description": "Fill in project development guidelines for AI agents",
"status": "completed",
"dev_type": "docs",
"scope": null,
"package": null,
"priority": "P1",
"creator": "cita-777",
"assignee": "cita-777",
"createdAt": "2026-08-31",
"completedAt": "2026-08-31",
"branch": null,
"base_branch": null,
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/spec/backend/",
".trellis/spec/frontend/"
],
"notes": "First-time setup task created by trellis init (fullstack project)",
"meta": {}
}
@@ -1,3 +0,0 @@
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"检查 focus、portal、aria 和组件复用"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"检查测试、移动和构建"}
{"file":".trellis/spec/guides/documentation-language.md","reason":"检查新增任务说明是否遵循中文文档约定"}
@@ -1,47 +0,0 @@
# 前端 UI 基础与 Radix 封装层:设计
## 组件分层
```text
page/domain component
↓ semantic props
src/web/components/ui/* (Metapi-owned API + theme/classes)
↓ behavior only
Radix primitives
```
第三方依赖只出现在 `components/ui`,并通过 CSS token 或现有 class 适配。
业务页面不得知道 Radix 的 `Root/Trigger/Content` 结构。
## 兼容 API
- `CenteredModal` 继续作为旧调用方 facade;内部可以委托新的 Dialog wrapper。
先补 role/aria-labelledby、focus return 和多实例 body-lock,再迁移调用方。
- `MobileDrawer` 继续提供现有 `open/onClose/title/side` 语义;桌面 drawer
与移动 drawer 共享底层 close/portal/focus 逻辑,但保留不同 CSS 尺寸。
- `ModernSelect` 保留当前 `options`、`value`、`onChange`、搜索和
placeholder props。Radix Select 只作为实现细节;若 Radix 的 test renderer
或 portal 行为不兼容,先保持旧内部实现,不能破坏 facade。
- `Button`/`AsyncButton` 只封装 `type`、`disabled`、`aria-busy`、loading
indicator 和现有 `.btn-*` variants,不把页面动作塞进一个 boolean 矩阵。
## 生命周期与无障碍
- 使用计数或 owner 集合管理 body scroll lock,避免两个 portal 同时存在时
一个卸载恢复滚动而另一个仍打开。
- Dialog/Drawer 明确 close policy;默认行为保持现有调用方设置,不擅自把
backdrop/Escape 从关闭变成开启。
- 打开时记录触发元素,关闭时优先返回仍连接的触发元素,其次使用现有
fallback focus;所有 close path 都清理 listener/timer。
- Toast 使用 `role=status`/`aria-live`,卸载时清理 timeout,保留现有 toast
去重与自动消失时间。
## 迁移顺序
1. 新增 wrapper 和测试,不动调用方;
2. 迁移低耦合的 Search/ChangeKey/settings modal;
3. 迁移下游/OAuth drawer 和 TokenRoutes confirm;
4. 保留 `ModernSelect` facade 后逐步切换内部行为;
5. 增量替换 Button/feedback,最后才考虑删除无用依赖或旧 CSS。
每一步都能单独回滚,且旧导出保持可用。
@@ -1,5 +0,0 @@
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"组件 props、Radix wrapper 和无障碍约束"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"前端测试和 drift gate"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"facade 与第三方类型边界"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"避免重复 modal/select/button"}
{"file":".trellis/spec/guides/documentation-language.md","reason":"保持 Trellis 实施文档和清单使用简体中文"}
@@ -1,35 +0,0 @@
# 前端 UI 基础与 Radix 封装层:执行清单
## 实施步骤
1. 核对 package manager 和 React 18 peer 兼容性,只加入实际需要的 Radix
包,保持 npm/pnpm lockfile 一致。
2. 建立 `src/web/components/ui/` 目录和第三方 import architecture test。
3. 提取共享 dialog/drawer 生命周期辅助逻辑,补多实例 body-lock、焦点返回、
Escape/backdrop、portal fallback 测试。
4. 让 `CenteredModal`、`MobileDrawer` 委托或复用新 wrapper,先确保旧测试通过。
5. 迁移 SearchModal、ChangeKeyModal、settings modal、downstream/OAuth drawer
和 TokenRoutes confirm/imperative dialog,逐个运行局部测试。
6. 增加 `Button`/`AsyncButton`、统一 loading/empty/error primitive,迁移至少
Accounts、Settings、Tokens 中的高频异步动作。
7. 让 Toast 提供 aria-live 和 timer cleanup,保持 `useToast` 合约。
8. 按 utility 搜索和 `build:web` 结果决定 Tailwind cleanup,并更新文档/guard。
## 重点验证
```bash
npm test -- src/web/components/centered-modal.test.tsx src/web/components/mobile-drawer.test.tsx src/web/components/ModernSelect.test.tsx
npm test -- src/web/pages/DownstreamKeys.test.tsx src/web/pages/settings.factory-reset.test.tsx src/web/pages/tokenRoutes.mobile-layout.test.tsx
npm run typecheck:web
npm run typecheck:web:test
npm run repo:drift-check
npm run build:web
git diff --check
```
## 停止条件
- Radix 改变 test renderer、focus、z-index、portal 或 controlled Select 语义且
无法通过 facade 兼容时,停止迁移并保留现有内部实现;
- 发现需要改协议/数据库或删除用户能力时,回到父任务评审;
- 不使用批量替换破坏用户已有 dirty/untracked 文件。
@@ -1,59 +0,0 @@
# 前端 UI 基础与 Radix 封装层
## 目标
采用 Iris 使用的组件边界:Radix 负责交互/无障碍原语,Metapi 自己的
`components/ui` 负责稳定 props、主题和兼容性,页面只使用项目封装。降低
重复弹窗、抽屉、选择器、按钮和反馈状态的维护成本,同时不改变现有页面能力。
## 已确认事实
- 当前没有 Radix 或完整 UI 套件依赖;`CenteredModal`、`MobileDrawer`、
`ModernSelect` 是已有共享原语,但多个页面仍复制外壳。
- `ChangeKeyModal`、`SearchModal`、多个 settings modal、下游/OAuth drawer
绕过或复制共享壳;`TokenRoutes` 还有 imperative `innerHTML` dialog。
- 生产代码约有 430 个原生 `<button>`,没有统一 `Button` 组件;异步按钮的
spinner、disabled、文案组合在页面中大量重复。
- `ModernSelect` 的 props 和组件类型被很多测试直接断言,必须保留 facade
兼容性,不能直接把 Radix 节点暴露给页面。
## 需求
1. 新增 `components/ui` 的项目封装边界,只有该边界可以直接 import
`@radix-ui/*`;页面和领域组件不得直接依赖第三方 UI 原语。
2. 提供兼容现有行为的 Dialog/Drawer wrapper:具备明确 role/aria、焦点返回、
Escape/backdrop 策略、body-scroll 引用计数和退出动画;保留现有 CSS class
与 `CenteredModal`/`MobileDrawer` 导出。
3. 将复制的 modal/drawer 和 imperative confirm 按低风险顺序迁移,保留
remember-dismiss、URL intent、portal fallback 和现有关闭策略。
4. 通过 facade 逐步让 `ModernSelect` 使用 Radix 行为,保留
`options/onChange/value/placeholder` 等当前测试和调用方契约。
5. 提供 `Button`/`AsyncButton`、`LoadingState`、`EmptyState`、`ErrorState` 或
等价的窄职责 primitive,至少迁移三个高频页面/组件;不强行抽取领域卡片。
6. 为 Toast 增加 `aria-live` 和 timer cleanup,但保持 `useToast` API 不变。
7. 增加 drift/architecture 守卫,阻止未来页面直接导入第三方 UI,并记录
暂时保留的兼容债务。
8. 根据实际 utility 使用率清理或明确保留 Tailwind 入口;不得新增第三种
样式运行时。
## 验收标准
- [ ] Dialog/Drawer/Select/Button/feedback wrapper 有行为或 architecture 测试,
覆盖关闭、焦点、body lock、键盘、loading/disabled 和 portal cleanup。
- [ ] 复制的 Search/ChangeKey/settings/downstream/OAuth modal/drawer 中至少
三个家族改为 wrapper,TokenRoutes 的 imperative dialog 不再新增;
现有功能和测试语义保持不变。
- [ ] 页面和领域组件没有新增第三方 UI 直引;drift check 对直接 import 可
报 violation,对 wrapper 目录可通过。
- [ ] `ModernSelect` 的现有调用方和测试无需改写为 Radix 原语,键盘/鼠标/受
控值行为有回归证据。
- [ ] 选定页面的重复 inline style/按钮/反馈 markup 明显减少,未改变主题、
移动断点和翻译;样式入口选择有构建证据。
- [ ] `npm run typecheck:web`、相关 Vitest、`npm run repo:drift-check`、
`git diff --check` 和 `npm run build:web` 通过。
## 范围外
- 全量重写所有页面、改变视觉品牌、引入 Arco/Ant Design 等完整视觉套件;
- 迁移业务表格/图表的领域渲染逻辑;
- 改变公开 API、协议、权限或数据库语义。
@@ -1,26 +0,0 @@
{
"id": "frontend-ui-foundation",
"name": "frontend-ui-foundation",
"title": "前端 UI 基础与 Radix 封装层",
"description": "采用 Iris-like 的 Radix 原语 + 项目封装层,收敛弹窗、抽屉、选择器、按钮和反馈状态,并建立唯一前端组件边界。",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "cita-777",
"assignee": "cita-777",
"createdAt": "2026-08-31",
"completedAt": "2026-08-31",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-31-project-wide-simplification",
"relatedFiles": [],
"notes": "",
"meta": {}
}
+23 -13
View File
@@ -496,6 +496,7 @@ describe('api proxy test timeout handling', () => {
controller.signal,
);
const bodyPromise = response.json();
await vi.advanceTimersByTimeAsync(30_000);
controller.abort();
await expect(bodyPromise).rejects.toMatchObject({ name: 'AbortError' });
@@ -530,28 +531,37 @@ describe('api proxy test timeout handling', () => {
});
});
it('normalizes a timeout from a returned streaming Response body', async () => {
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
Promise.resolve(createAbortAwarePendingResponse(init?.signal)),
it('releases the initial deadline after a proxy stream is established', async () => {
let sourceController: ReadableStreamDefaultController<Uint8Array> | undefined;
const encoder = new TextEncoder();
const fetchMock = vi.fn().mockResolvedValue(
new Response(new ReadableStream<Uint8Array>({
start(controller) {
sourceController = controller;
controller.enqueue(encoder.encode('first'));
},
}), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const response = await api.proxyTestStream({
method: 'POST',
path: '/v1/embeddings',
path: '/v1/chat/completions',
requestKind: 'json',
jsonBody: { model: 'demo', input: 'hello' },
stream: true,
jsonBody: { model: 'demo', messages: [] },
});
const bodyResult = response.json().then(
(value) => ({ ok: true as const, value }),
(error) => ({ ok: false as const, error }),
);
const reader = response.body!.getReader();
await expect(reader.read()).resolves.toMatchObject({ done: false });
await vi.advanceTimersByTimeAsync(30_000);
await expect(bodyResult).resolves.toMatchObject({
ok: false,
error: { message: '请求超时(30s)' },
});
expect(vi.getTimerCount()).toBe(0);
const secondRead = reader.read();
sourceController!.enqueue(encoder.encode('second'));
sourceController!.close();
await expect(secondRead).resolves.toMatchObject({ done: false });
await expect(reader.read()).resolves.toMatchObject({ done: true });
});
it('cancels a returned body promptly even when its source ignores abort', async () => {
+21 -6
View File
@@ -14,6 +14,8 @@ type RequestOptions = RequestInit & {
type FetchResponseOptions = {
/** Wrap a returned stream when the caller owns the Response lifecycle. */
wrapBody?: boolean;
/** Stop the initial request deadline once response headers are available. */
releaseTimeoutAfterHeaders?: boolean;
};
type ResponseLifecycle = {
@@ -22,6 +24,7 @@ type ResponseLifecycle = {
externalSignal?: AbortSignal;
timedOut: boolean;
externallyAborted: boolean;
releaseTimeout: () => void;
cleanup: () => void;
normalizeError: (error: unknown) => unknown;
};
@@ -59,6 +62,11 @@ function createResponseLifecycle(
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let externalAbortHandler: (() => void) | null = null;
let cleaned = false;
const releaseTimeout = () => {
if (timeoutHandle === null) return;
clearTimeout(timeoutHandle);
timeoutHandle = null;
};
const lifecycle: ResponseLifecycle = {
controller,
@@ -66,13 +74,11 @@ function createResponseLifecycle(
externalSignal,
timedOut: false,
externallyAborted: !!externalSignal?.aborted,
releaseTimeout,
cleanup: () => {
if (cleaned) return;
cleaned = true;
if (timeoutHandle !== null) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
releaseTimeout();
if (externalSignal && externalAbortHandler) {
externalSignal.removeEventListener("abort", externalAbortHandler);
externalAbortHandler = null;
@@ -221,6 +227,7 @@ async function consumeResponseBody<T>(
function attachResponseLifecycle(
response: Response,
lifecycle: ResponseLifecycle,
releaseTimeoutAfterHeaders = false,
): Response {
// A native Response with a null body has nothing left to wait for. Keep a
// lifecycle for test/adaptor response doubles that omit `body` but expose a
@@ -234,6 +241,10 @@ function attachResponseLifecycle(
return response;
}
if (releaseTimeoutAfterHeaders) {
lifecycle.releaseTimeout();
}
if (body == null) {
responseLifecycles.set(response, lifecycle);
return response;
@@ -529,7 +540,11 @@ async function fetchAuthenticatedResponse(
throw new Error("Session expired");
}
if (responseOptions.wrapBody) {
return attachResponseLifecycle(res, lifecycle);
return attachResponseLifecycle(
res,
lifecycle,
responseOptions.releaseTimeoutAfterHeaders,
);
}
const body = res.body;
const isNativeResponse =
@@ -750,7 +765,7 @@ async function proxyTestStreamRequest(
body: JSON.stringify(data),
timeoutMs: resolveProxyTestTimeoutMs(data),
},
{ wrapBody: true },
{ wrapBody: true, releaseTimeoutAfterHeaders: true },
);
}
+34 -40
View File
@@ -850,6 +850,40 @@ export default function ModelTester() {
setParameterEnabled((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
streamStopRequestedRef.current = true;
streamGenerationRef.current += 1;
streamBatcherRef.current?.flush();
streamBatcherRef.current?.dispose();
streamBatcherRef.current = null;
const reader = streamReaderRef.current;
streamReaderRef.current = null;
cancelStreamReader(reader);
try {
streamAbortRef.current?.abort();
} catch {
// no-op
}
streamAbortRef.current = null;
if (scrollTimerRef.current !== null) {
clearTimeout(scrollTimerRef.current);
scrollTimerRef.current = null;
}
if (persistenceTimerRef.current !== null) {
clearTimeout(persistenceTimerRef.current);
persistenceTimerRef.current = null;
if (!skipPersistenceRef.current) {
persistSessionRef.current?.();
}
}
pendingPollAbortRef.current?.abort();
pendingPollAbortRef.current = null;
};
}, []);
useEffect(() => {
const restored = typeof localStorage === 'undefined'
? null
@@ -1114,46 +1148,6 @@ export default function ModelTester() {
};
}, [messages, sending]);
useEffect(() => {
// React StrictMode replays mount effects in development. Resetting this
// flag in setup keeps the second (real) mount live after the probe cleanup.
mountedRef.current = true;
return () => {
mountedRef.current = false;
streamStopRequestedRef.current = true;
streamGenerationRef.current += 1;
// Flush before disposal so a teardown cannot leave a scheduled batch (or
// its timer) behind. The mounted guard suppresses React state commits,
// while the batcher still drains its raw/delta queues deterministically.
streamBatcherRef.current?.flush();
streamBatcherRef.current?.dispose();
streamBatcherRef.current = null;
const reader = streamReaderRef.current;
streamReaderRef.current = null;
cancelStreamReader(reader);
try {
streamAbortRef.current?.abort();
} catch {
// no-op
}
streamAbortRef.current = null;
if (scrollTimerRef.current !== null) {
clearTimeout(scrollTimerRef.current);
scrollTimerRef.current = null;
}
if (persistenceTimerRef.current !== null) {
clearTimeout(persistenceTimerRef.current);
persistenceTimerRef.current = null;
}
// Do not write a snapshot during teardown: a replacement ModelTester
// instance may already own the storage key, and an old cleanup must not
// overwrite its newer state. Normal debounce commits happen before
// teardown; clearChat explicitly suppresses the next commit.
pendingPollAbortRef.current?.abort();
pendingPollAbortRef.current = null;
};
}, []);
const handleUploadChange = useCallback(async (
fileList: FileList | null,
setter: React.Dispatch<React.SetStateAction<UploadState | null>>,
@@ -0,0 +1,249 @@
/** @vitest-environment jsdom */
import { act, StrictMode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ModelTester from './ModelTester.js';
import {
DEBUG_TABS,
DEFAULT_INPUTS,
DEFAULT_MODE_STATE,
DEFAULT_PARAMETER_ENABLED,
MODEL_TESTER_STORAGE_KEY,
serializeModelTesterSession,
type ModelTesterSessionState,
} from './helpers/modelTesterSession.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getModelsMarketplace: vi.fn(),
getRoutes: vi.fn(),
getRouteDecision: vi.fn(),
proxyTestStream: vi.fn(),
getProxyTestJob: vi.fn(),
deleteProxyTestJob: vi.fn(),
},
}));
type ComposerMockProps = {
input: string;
onInputChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
};
type SelectMockProps = {
value: string;
disabled?: boolean;
onChange: (value: string) => void;
options?: Array<{ value: string; label: string }>;
'data-testid'?: string;
};
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('../authSession.js', () => ({
clearAuthSession: vi.fn(),
getAuthToken: vi.fn(() => null),
}));
vi.mock('../components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: () => ({ shouldRender: false, isVisible: false }),
}));
vi.mock('../components/useIsMobile.js', () => ({ useIsMobile: () => false }));
vi.mock('../i18n.js', () => ({ tr: (value: string) => value }));
vi.mock('./model-tester/DebugPanel.js', () => ({
default: () => null,
}));
vi.mock('./model-tester/ConversationComposer.js', () => ({
default: (props: ComposerMockProps) => (
<div>
<textarea
data-testid="composer-input"
value={props.input}
onChange={(event) => props.onInputChange(event.target.value)}
/>
<button type="button" data-testid="set-input" onClick={() => props.onInputChange('latest prompt')}>set input</button>
<button type="button" data-testid="send" onClick={() => props.onSend()}>send</button>
<button type="button" data-testid="stop" onClick={() => props.onStop()}>stop</button>
</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: (props: SelectMockProps) => (
<select
data-testid={props['data-testid']}
value={props.value}
disabled={props.disabled}
onChange={(event) => props.onChange(event.target.value)}
>
{(props.options || []).map((option: { value: string; label: string }) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
const flushMicrotasks = async (rounds = 8) => {
for (let index = 0; index < rounds; index += 1) {
await Promise.resolve();
}
};
const createStorage = (initial?: string) => {
const values = new Map<string, string>();
if (initial !== undefined) values.set(MODEL_TESTER_STORAGE_KEY, initial);
return {
values,
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
};
};
const createSession = (overrides: Partial<ModelTesterSessionState> = {}) => serializeModelTesterSession({
input: '',
inputs: { ...DEFAULT_INPUTS, model: 'gpt-test' },
parameterEnabled: DEFAULT_PARAMETER_ENABLED,
messages: [],
conversationFiles: [],
pendingPayload: null,
pendingJobId: null,
forcedChannelId: null,
customRequestMode: false,
customRequestBody: '',
showDebugPanel: false,
activeDebugTab: DEBUG_TABS.PREVIEW,
modeState: DEFAULT_MODE_STATE,
...overrides,
});
async function renderTester(): Promise<{ root: Root; container: HTMLDivElement }> {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
<StrictMode>
<ModelTester />
</StrictMode>,
);
await flushMicrotasks();
});
return { root, container };
}
describe('ModelTester lifecycle', () => {
let storage: ReturnType<typeof createStorage>;
beforeEach(() => {
vi.clearAllMocks();
apiMock.getModelsMarketplace.mockResolvedValue({ models: [{ name: 'gpt-test' }] });
apiMock.getRoutes.mockResolvedValue([]);
apiMock.getRouteDecision.mockResolvedValue({ decision: { candidates: [] } });
apiMock.deleteProxyTestJob.mockResolvedValue({});
storage = createStorage();
vi.stubGlobal('localStorage', storage);
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('finishes model hydration when mounted under React StrictMode', async () => {
let root: Root | undefined;
try {
const rendered = await renderTester();
root = rendered.root;
await act(async () => {
await flushMicrotasks(12);
});
expect(rendered.container.querySelector('.skeleton')).toBeNull();
expect(rendered.container.textContent).toContain('gpt-test');
expect(apiMock.getModelsMarketplace.mock.calls.length).toBeGreaterThanOrEqual(2);
} finally {
if (root) {
await act(async () => {
root?.unmount();
});
}
}
});
it('flushes the latest session snapshot during unmount', async () => {
vi.useFakeTimers();
let root: Root | undefined;
try {
const rendered = await renderTester();
root = rendered.root;
await act(async () => {
await flushMicrotasks(12);
});
const input = rendered.container.querySelector<HTMLTextAreaElement>('[data-testid="composer-input"]');
const setInputButton = rendered.container.querySelector<HTMLButtonElement>('[data-testid="set-input"]');
expect(input).not.toBeNull();
expect(setInputButton).not.toBeNull();
await act(async () => {
setInputButton!.click();
await flushMicrotasks();
});
expect(storage.values.has(MODEL_TESTER_STORAGE_KEY)).toBe(false);
await act(async () => {
root?.unmount();
});
root = undefined;
const persisted = JSON.parse(storage.values.get(MODEL_TESTER_STORAGE_KEY) || '{}') as Record<string, unknown>;
expect(persisted.input).toBe('latest prompt');
} finally {
if (root) {
await act(async () => {
root?.unmount();
});
}
}
});
it('does not recreate the session after clearChat followed by unmount', async () => {
vi.useFakeTimers();
let root: Root | undefined;
storage = createStorage(createSession({
messages: [{ id: 'msg-1', role: 'user', content: 'old', createAt: 1 }],
}));
vi.stubGlobal('localStorage', storage);
try {
const rendered = await renderTester();
root = rendered.root;
await act(async () => {
await flushMicrotasks(12);
});
const clearButton = Array.from(rendered.container.querySelectorAll('button'))
.find((button) => button.textContent?.trim() === '清除');
expect(clearButton).toBeDefined();
await act(async () => {
clearButton!.click();
await flushMicrotasks();
});
expect(storage.values.has(MODEL_TESTER_STORAGE_KEY)).toBe(false);
await act(async () => {
root?.unmount();
});
root = undefined;
expect(storage.values.has(MODEL_TESTER_STORAGE_KEY)).toBe(false);
} finally {
if (root) {
await act(async () => {
root?.unmount();
});
}
}
});
});
@@ -156,7 +156,9 @@ describe('ModelTester stream batching', () => {
.filter((snapshot) => snapshot.includes('[DONE]'));
expect(new Set(rawSnapshots).size).toBe(1);
} finally {
root?.unmount();
await act(async () => {
root?.unmount();
});
}
});
@@ -204,7 +206,9 @@ describe('ModelTester stream batching', () => {
expect(collectText(root!.toJSON())).toContain('a');
expect(readerCancelled).toBe(true);
} finally {
root?.unmount();
await act(async () => {
root?.unmount();
});
vi.useRealTimers();
}
});
@@ -241,7 +245,9 @@ describe('ModelTester stream batching', () => {
expect(signal.aborted).toBe(true);
expect(apiMock.deleteProxyTestJob).toHaveBeenCalledWith('job-42');
} finally {
root?.unmount();
await act(async () => {
root?.unmount();
});
}
});
});