Просмотр исходного кода

chore: remove legacy web frontend

Sam Lee 4 недель назад
Родитель
Сommit
85fcae894c
42 измененных файлов с 0 добавлено и 8896 удалено
  1. 0 4
      web/.gitignore
  2. 0 49
      web/README.md
  3. 0 5
      web/app/config/page.tsx
  4. 0 2074
      web/app/globals.css
  5. 0 19
      web/app/layout.tsx
  6. 0 5
      web/app/page.tsx
  7. 0 10
      web/app/runs/[runId]/page.tsx
  8. 0 6
      web/app/runs/[runId]/timeline/page.tsx
  9. 0 7
      web/app/runs/page.tsx
  10. 0 29
      web/components/badges/StatusBadge.tsx
  11. 0 14
      web/components/cards/FactGrid.tsx
  12. 0 8
      web/components/cards/MetricCard.tsx
  13. 0 31
      web/components/cards/RecordList.tsx
  14. 0 59
      web/components/layout/AppShell.tsx
  15. 0 29
      web/components/pipeline/PipelineHeader.tsx
  16. 0 333
      web/docs/01_产品功能规划.md
  17. 0 300
      web/docs/02_数据与接口规划.md
  18. 0 437
      web/docs/03_技术开发规划.md
  19. 0 288
      web/docs/04_show视觉迁移清单.md
  20. 0 413
      web/docs/05_Web实施简报.md
  21. 0 37
      web/docs/06_WebV2迭代计划.md
  22. 0 102
      web/features/config/ConfigPage.tsx
  23. 0 310
      web/features/config/configTables.tsx
  24. 0 330
      web/features/runs/ContentAssetShelf.tsx
  25. 0 162
      web/features/runs/DemandDetail.tsx
  26. 0 532
      web/features/runs/RunDashboardPage.tsx
  27. 0 176
      web/features/runs/RunListPage.tsx
  28. 0 234
      web/features/runs/RunTimelinePage.tsx
  29. 0 381
      web/features/runs/StrategyConfigPanel.tsx
  30. 0 794
      web/features/runs/WalkJourney.tsx
  31. 0 62
      web/features/runs/queryPrompt.ts
  32. 0 45
      web/lib/adapters/pipeline.ts
  33. 0 102
      web/lib/api/client.ts
  34. 0 212
      web/lib/api/types.ts
  35. 0 98
      web/lib/config/fieldLabels.ts
  36. 0 68
      web/lib/platform/content.ts
  37. 0 40
      web/lib/status/status.ts
  38. 0 6
      web/next-env.d.ts
  39. 0 7
      web/next.config.ts
  40. 0 1033
      web/package-lock.json
  41. 0 22
      web/package.json
  42. 0 23
      web/tsconfig.json

+ 0 - 4
web/.gitignore

@@ -1,4 +0,0 @@
-.next/
-node_modules/
-tsconfig.tsbuildinfo
-npm-debug.log*

+ 0 - 49
web/README.md

@@ -1,49 +0,0 @@
-# ContentFindAgent Web
-
-`web/` 是新的可视化面板项目目录。后续 Next.js 代码、组件、API client、页面和前端测试都放在这里;旧 `show/` 只作为视觉和交互参考,不再作为真实数据实现底座。
-
-## 定位
-
-新 Web 的目标是把 ContentFindAgent 的真实运行数据展示给用户:
-
-- 查看 run 列表和运行状态。
-- 进入某次 run,按阶段查看数据源、Query、平台召回、规则判断、游走、资产沉淀和策略学习。
-- 诊断失败 run,例如平台失败、Query 生成失败、runtime 缺文件、validation 不通过。
-- 复盘哪些 Query、规则、游走路径和资产值得保留或调整。
-
-## 和 show 的关系
-
-`show/` 当前是旧静态沙盘:视觉完整,但数据主要写死在 `show/src/App.tsx`,不连接 FastAPI,也不是生产事实层。
-
-新 Web 要做到:
-
-- 视觉和交互尽量贴近 `show`。
-- 数据层和组件结构全部重写。
-- 不迁移 `show` 的静态样例作为事实数据。
-- 不把小红书、热点、养号、共创、相似作者等旧占位入口当成 V1 默认能力。
-
-## 数据原则
-
-- 生产事实层:FastAPI 读取云 MySQL `content_agent_*` 表或后端聚合后的事实数据。
-- 回放辅助层:本地 `runtime/v1/{run_id}/` JSON / JSONL 只作为开发调试和回放导出,UI 必须标注为“回放导出”。
-- 前端不直接连接 MySQL。
-- 前端不直接读取任意文件路径。
-- runtime 文件读取接口必须使用后端白名单。
-
-## 文档
-
-- [01_产品功能规划.md](docs/01_产品功能规划.md)
-- [02_数据与接口规划.md](docs/02_数据与接口规划.md)
-- [03_技术开发规划.md](docs/03_技术开发规划.md)
-- [04_show视觉迁移清单.md](docs/04_show视觉迁移清单.md)
-- [05_Web实施简报.md](docs/05_Web实施简报.md)
-
-## 后续实施顺序
-
-1. 补后端 Web 友好 API。(已完成,commit 6ee0079)
-2. 初始化 `web/` 下 Next.js 项目。(已完成,commit c6c5e39)
-3. 建立 API client、类型和数据 adapter。(已完成)
-4. 复刻 `show` 的页面视觉与核心交互。(已完成基础版)
-5. 用真实 run 数据替换静态演示数据。(已完成)
-
-Web V2 迭代(时间线/配置只读/V1 bug 修复)见 `docs/06_WebV2迭代计划.md`。

+ 0 - 5
web/app/config/page.tsx

@@ -1,5 +0,0 @@
-import { ConfigPage } from "@/features/config/ConfigPage";
-
-export default function Page() {
-  return <ConfigPage />;
-}

+ 0 - 2074
web/app/globals.css

@@ -1,2074 +0,0 @@
-:root {
-  color: #172033;
-  background: #f6f8fb;
-  font-family:
-    Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui,
-    -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-  font-synthesis: none;
-  text-rendering: optimizeLegibility;
-}
-
-* {
-  box-sizing: border-box;
-}
-
-body {
-  min-width: 1120px;
-  min-height: 100vh;
-  margin: 0;
-  background: #f6f8fb;
-}
-
-button,
-input,
-select {
-  font: inherit;
-}
-
-a {
-  color: inherit;
-  text-decoration: none;
-}
-
-.app-shell {
-  min-height: 100vh;
-}
-
-.workspace {
-  min-width: 0;
-  padding: 10px 20px 24px;
-}
-
-.topbar {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 16px;
-  min-height: 46px;
-  margin-bottom: 8px;
-}
-
-.topbar-left {
-  display: flex;
-  align-items: center;
-  justify-content: flex-start;
-  gap: 12px;
-}
-
-.topbar-center {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  min-width: 0;
-}
-
-/* tab 上提到顶栏中部:去掉独立成行的下边距 */
-.topbar-center .panel-nav {
-  margin-bottom: 0;
-  flex-wrap: nowrap;
-}
-.topbar-center .panel-nav-tab {
-  padding: 6px 13px;
-}
-
-.brand {
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  text-align: center;
-}
-
-.back-link {
-  width: fit-content;
-  min-height: 34px;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  gap: 6px;
-  padding: 0 11px;
-  border: 1px solid #cfdceb;
-  border-radius: 8px;
-  color: #31415a;
-  background: #ffffff;
-  font-size: 12px;
-  font-weight: 900;
-  box-shadow: 0 1px 2px rgba(20, 39, 66, 0.04);
-}
-
-.back-link:hover {
-  border-color: #9cbce8;
-  color: #2360ad;
-  background: #f8fbff;
-}
-
-.brand strong {
-  color: #172033;
-  font-size: 16px;
-  font-weight: 950;
-}
-
-.toolbar {
-  display: flex;
-  align-items: center;
-  justify-content: flex-end;
-  gap: 8px;
-}
-
-.icon-button,
-.text-button {
-  min-height: 36px;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  gap: 8px;
-  border: 1px solid #cfdceb;
-  border-radius: 8px;
-  color: #22304a;
-  background: #ffffff;
-  font-size: 13px;
-  font-weight: 900;
-  cursor: pointer;
-}
-
-.icon-button {
-  width: 38px;
-}
-
-.text-button {
-  padding: 0 12px;
-}
-
-.icon-button:hover,
-.text-button:hover {
-  border-color: #9cbce8;
-  color: #2360ad;
-  background: #f8fbff;
-}
-
-.panel,
-.pipeline-header,
-.filter-bar,
-.list-panel,
-.detail-panel {
-  border: 1px solid #dfe5ee;
-  border-radius: 8px;
-  background: #ffffff;
-}
-
-.pipeline-header {
-  padding: 0 8px;
-  overflow-x: auto;
-}
-
-.pipeline-grid {
-  min-width: 1430px;
-  display: grid;
-  grid-template-columns: repeat(7, 196px);
-  gap: 8px;
-}
-
-.pipeline-step {
-  min-height: 56px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: 8px;
-  border: 1px solid transparent;
-  border-radius: 8px;
-  color: #5d6b82;
-  background: transparent;
-  font-size: 14px;
-  font-weight: 900;
-  text-align: center;
-}
-
-.pipeline-step.active {
-  color: #2360ad;
-  border-color: #bcd5fb;
-  background: #eaf3ff;
-  box-shadow: inset 0 0 0 1px #d8e8ff;
-}
-
-.pipeline-step.warn {
-  color: #9b4f15;
-  background: #fff6e8;
-  border-color: #f3c985;
-}
-
-.filter-bar {
-  min-height: 70px;
-  display: flex;
-  align-items: center;
-  gap: 14px;
-  margin-top: 8px;
-  padding: 12px 16px;
-}
-
-.filter-title {
-  min-width: 240px;
-  display: grid;
-  gap: 4px;
-}
-
-.filter-title span {
-  color: #8491a6;
-  font-size: 11px;
-  font-weight: 900;
-}
-
-.filter-title strong {
-  color: #172033;
-  font-size: 15px;
-  font-weight: 900;
-}
-
-.field {
-  display: inline-flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.field span {
-  color: #78869a;
-  font-size: 13px;
-  font-weight: 900;
-  white-space: nowrap;
-}
-
-.input,
-.select {
-  min-height: 34px;
-  border: 1px solid #cfdceb;
-  border-radius: 8px;
-  color: #172033;
-  background: #ffffff;
-  font-size: 13px;
-  font-weight: 800;
-  outline: none;
-}
-
-.input {
-  width: 260px;
-  padding: 0 11px;
-}
-
-.select {
-  width: 150px;
-  padding: 0 10px;
-}
-
-.main-grid {
-  display: grid;
-  grid-template-columns: 380px minmax(0, 1fr);
-  gap: 10px;
-  margin-top: 10px;
-}
-
-.list-panel {
-  min-height: 640px;
-  padding: 10px;
-}
-
-.detail-panel {
-  min-height: 640px;
-  padding: 16px;
-}
-
-.run-card,
-.section,
-.metric,
-.record-row {
-  border: 1px solid #dfe5ee;
-  border-radius: 8px;
-  background: #ffffff;
-}
-
-.run-card {
-  display: grid;
-  gap: 8px;
-  width: 100%;
-  margin-bottom: 8px;
-  padding: 12px;
-  text-align: left;
-  cursor: pointer;
-}
-
-.run-card.active,
-.run-card:hover {
-  border-color: #9cbce8;
-  background: #f8fbff;
-}
-
-.run-card-title {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 8px;
-  color: #172033;
-  font-size: 13px;
-  font-weight: 950;
-}
-
-.run-card-meta {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-  color: #6d7c93;
-  font-size: 12px;
-  font-weight: 800;
-}
-
-.run-card-time {
-  color: #2f3b52;
-}
-
-.run-card-err {
-  color: #9d2020;
-}
-
-.detail-header {
-  display: flex;
-  align-items: flex-start;
-  justify-content: space-between;
-  gap: 14px;
-  margin-bottom: 14px;
-}
-
-.detail-title {
-  display: grid;
-  gap: 5px;
-}
-
-.detail-title span {
-  color: #8491a6;
-  font-size: 11px;
-  font-weight: 900;
-}
-
-.detail-title strong {
-  color: #172033;
-  font-size: 21px;
-  font-weight: 950;
-}
-
-.badge-row {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-}
-
-.badge {
-  min-height: 24px;
-  display: inline-flex;
-  align-items: center;
-  gap: 5px;
-  padding: 0 8px;
-  border-radius: 8px;
-  color: #526178;
-  background: #eef2f7;
-  font-size: 11px;
-  font-weight: 950;
-  white-space: nowrap;
-}
-
-.badge.success {
-  color: #12604b;
-  background: #dff7ed;
-}
-
-.badge.failed,
-.badge.rule_blocked {
-  color: #9d2020;
-  background: #fee4e2;
-}
-
-.badge.pending,
-.badge.partial_success {
-  color: #8a5314;
-  background: #fff2d7;
-}
-
-.badge.production_db {
-  color: #155a86;
-  background: #e3f2ff;
-}
-
-.badge.runtime_export,
-.badge.mixed_with_runtime_export {
-  color: #7051a3;
-  background: #f0eaff;
-}
-
-.metrics-grid {
-  display: grid;
-  grid-template-columns: repeat(6, minmax(0, 1fr));
-  gap: 8px;
-  margin-bottom: 14px;
-}
-
-.metric {
-  min-height: 72px;
-  display: grid;
-  align-content: center;
-  gap: 5px;
-  padding: 12px;
-}
-
-.metric span {
-  color: #8491a6;
-  font-size: 11px;
-  font-weight: 900;
-}
-
-.metric strong {
-  color: #172033;
-  font-size: 20px;
-  font-weight: 950;
-}
-
-.stage-layout {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr) 330px;
-  gap: 10px;
-}
-
-.section {
-  margin-bottom: 10px;
-  padding: 13px;
-}
-
-.section-title {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 10px;
-  margin-bottom: 10px;
-}
-
-.section-title strong {
-  display: inline-flex;
-  align-items: center;
-  gap: 7px;
-  color: #172033;
-  font-size: 14px;
-  font-weight: 950;
-}
-
-.record-list {
-  display: grid;
-  gap: 8px;
-}
-
-.record-row {
-  display: grid;
-  gap: 6px;
-  padding: 10px;
-}
-
-.record-row strong {
-  color: #172033;
-  font-size: 13px;
-  font-weight: 950;
-}
-
-.record-row span,
-.muted {
-  color: #6d7c93;
-  font-size: 12px;
-  font-weight: 800;
-}
-
-.fact-grid {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 8px;
-}
-
-.fact {
-  min-height: 46px;
-  display: grid;
-  gap: 3px;
-  padding: 8px 10px;
-  border: 1px solid #e4eaf2;
-  border-radius: 8px;
-  background: #f9fbfd;
-}
-
-.fact span {
-  color: #8491a6;
-  font-size: 11px;
-  font-weight: 900;
-}
-
-.fact strong {
-  color: #172033;
-  font-size: 13px;
-  font-weight: 950;
-  overflow-wrap: anywhere;
-}
-
-.drawer-like {
-  position: sticky;
-  top: 10px;
-}
-
-.empty-state,
-.error-state,
-.loading-state {
-  min-height: 190px;
-  display: grid;
-  place-items: center;
-  border: 1px dashed #cbd6e4;
-  border-radius: 8px;
-  color: #697891;
-  background: #fbfcfe;
-  font-size: 13px;
-  font-weight: 900;
-  text-align: center;
-}
-
-.error-state {
-  color: #9d2020;
-  background: #fff8f7;
-  border-color: #f3b7b2;
-}
-
-.code-block {
-  max-height: 360px;
-  overflow: auto;
-  margin: 0;
-  padding: 12px;
-  border-radius: 8px;
-  color: #203048;
-  background: #f3f6fa;
-  font-size: 12px;
-  line-height: 1.55;
-  white-space: pre-wrap;
-  overflow-wrap: anywhere;
-}
-
-.business-page {
-  display: grid;
-  gap: 12px;
-  /* 行按内容高度顶部对齐:避免内容短于 min-height 时,多余空间被均摊到漏斗带把它撑高(切 tab 时行高忽高忽低) */
-  align-content: start;
-}
-
-.business-alert {
-  display: inline-flex;
-  align-items: center;
-  gap: 8px;
-  width: fit-content;
-  padding: 8px 10px;
-  border: 1px solid #f0c5a1;
-  border-radius: 8px;
-  color: #8a5314;
-  background: #fff7ed;
-  font-size: 12px;
-  font-weight: 900;
-}
-
-.business-alert.compact {
-  width: 100%;
-}
-
-.stage-nav-grid {
-  display: grid;
-  grid-template-columns: repeat(7, minmax(0, 1fr));
-  gap: 8px;
-}
-
-.stage-nav-card {
-  min-height: 116px;
-  display: grid;
-  grid-template-rows: auto 1fr;
-  flex-direction: column;
-  gap: 10px;
-  padding: 12px;
-  border: 1px solid #d7e3f4;
-  border-radius: 8px;
-  color: #172033;
-  background: #ffffff;
-  text-align: left;
-  cursor: pointer;
-  box-shadow: 0 1px 2px rgba(20, 39, 66, 0.04);
-}
-
-.stage-nav-card.active {
-  border-color: #6ea8f7;
-  color: #124f9f;
-  background: #eaf3ff;
-  box-shadow: inset 0 0 0 1px #bcd7ff, 0 6px 18px rgba(47, 101, 173, 0.12);
-}
-
-.stage-nav-card:hover {
-  border-color: #9cbce8;
-  background: #f8fbff;
-}
-
-.stage-nav-card.success {
-  border-color: #bce6d4;
-}
-
-.stage-nav-card.failed,
-.stage-nav-card.rule_blocked {
-  border-color: #f0bbb8;
-}
-
-.stage-nav-card.pending {
-  border-color: #f3d295;
-}
-
-.stage-nav-card.failed.active,
-.stage-nav-card.rule_blocked.active {
-  border-color: #ed8f88;
-  color: #8f1d17;
-  background: #fff1f0;
-  box-shadow: inset 0 0 0 1px #fac8c4, 0 6px 18px rgba(190, 72, 64, 0.1);
-}
-
-.stage-nav-card.pending.active {
-  border-color: #e9b65f;
-  color: #86520e;
-  background: #fff7e7;
-  box-shadow: inset 0 0 0 1px #f4d49a, 0 6px 18px rgba(176, 111, 25, 0.1);
-}
-
-.stage-nav-top {
-  min-height: 24px;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 8px;
-}
-
-.stage-nav-top strong {
-  color: inherit;
-  font-size: 14px;
-  font-weight: 950;
-}
-
-.stage-nav-count {
-  min-width: 26px;
-  height: 24px;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  padding: 0 8px;
-  border-radius: 999px;
-  color: #51657f;
-  background: #e9eef6;
-  font-size: 12px;
-  font-weight: 950;
-}
-
-.stage-nav-card.active .stage-nav-count {
-  color: #0f4d9f;
-  background: #d7e8ff;
-}
-
-.stage-nav-body {
-  display: grid;
-  align-content: space-between;
-  gap: 8px;
-  min-width: 0;
-}
-
-.stage-nav-body span,
-.stage-nav-body small {
-  color: #8491a6;
-  font-size: 11px;
-  font-weight: 900;
-}
-
-.stage-nav-body span {
-  color: #172033;
-  font-size: 13px;
-  font-weight: 950;
-  line-height: 1.35;
-}
-
-.stage-nav-card.active .stage-nav-body span {
-  color: inherit;
-}
-
-.business-section {
-  min-height: 430px;
-  padding: 15px;
-  border: 1px solid #dfe5ee;
-  border-radius: 8px;
-  background: #ffffff;
-}
-
-.conclusion-body {
-  display: grid;
-  grid-template-columns: auto minmax(0, 1fr);
-  gap: 6px 10px;
-  align-items: center;
-  margin-bottom: 12px;
-  padding: 12px;
-  border: 1px solid #e4eaf2;
-  border-radius: 8px;
-  background: #f9fbfd;
-}
-
-.conclusion-body strong {
-  color: #172033;
-  font-size: 14px;
-  font-weight: 950;
-}
-
-.conclusion-body span:last-child {
-  grid-column: 1 / -1;
-  color: #65748c;
-  font-size: 13px;
-  font-weight: 850;
-}
-
-.business-action-row {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
-}
-
-.source-summary-grid {
-  display: grid;
-  grid-template-columns: repeat(5, minmax(0, 1fr));
-  gap: 8px;
-  margin-bottom: 12px;
-}
-
-.source-summary-grid > div {
-  display: grid;
-  gap: 5px;
-  min-height: 92px;
-  padding: 10px;
-  border: 1px solid #dfe5ee;
-  border-radius: 8px;
-  background: #ffffff;
-}
-
-.source-summary-grid span,
-.source-summary-grid small,
-.query-meta-line {
-  color: #8491a6;
-  font-size: 11px;
-  font-weight: 850;
-}
-
-.source-summary-grid strong {
-  color: #172033;
-  font-size: 13px;
-  font-weight: 950;
-  line-height: 1.4;
-  overflow-wrap: anywhere;
-}
-
-.query-meta-line {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px 12px;
-}
-
-.business-card-list {
-  display: grid;
-  gap: 10px;
-}
-
-.content-card-heading {
-  display: grid;
-  gap: 5px;
-}
-
-.content-card-heading > span,
-.content-meta-grid > span {
-  color: #8491a6;
-  font-size: 11px;
-  font-weight: 900;
-}
-
-.content-card-heading strong {
-  color: #172033;
-  font-size: 14px;
-  font-weight: 950;
-  line-height: 1.45;
-}
-
-.content-meta-grid {
-  display: grid;
-  grid-template-columns: repeat(5, minmax(0, 1fr));
-  gap: 8px;
-}
-
-.content-meta-grid > span {
-  display: grid;
-  gap: 4px;
-  min-height: 58px;
-  padding: 8px;
-  border: 1px solid #e4eaf2;
-  border-radius: 8px;
-  background: #f9fbfd;
-}
-
-.content-meta-grid strong {
-  color: #172033;
-  font-size: 12px;
-  font-weight: 950;
-  overflow-wrap: anywhere;
-}
-
-.business-record-card,
-.rule-application-card {
-  display: grid;
-  gap: 9px;
-  padding: 12px;
-  border: 1px solid #dfe5ee;
-  border-radius: 8px;
-  background: #ffffff;
-}
-
-.rule-card-heading {
-  display: grid;
-  gap: 4px;
-}
-
-.rule-card-heading > span,
-.rule-card-heading small,
-.rule-application-flow small {
-  color: #8491a6;
-  font-size: 11px;
-  font-weight: 850;
-}
-
-.business-record-card strong,
-.rule-application-card strong {
-  color: #172033;
-  font-size: 14px;
-  font-weight: 950;
-}
-
-.business-record-card span,
-.rule-application-card span {
-  color: #65748c;
-  font-size: 12px;
-  font-weight: 850;
-  overflow-wrap: anywhere;
-}
-
-.rule-chain {
-  display: flex;
-  flex-wrap: wrap;
-  align-items: center;
-  gap: 8px;
-  margin-bottom: 12px;
-  padding: 10px;
-  border: 1px solid #dfe5ee;
-  border-radius: 8px;
-  background: #f8fbff;
-}
-
-.rule-chain span {
-  padding: 6px 9px;
-  border-radius: 8px;
-  color: #2360ad;
-  background: #eaf3ff;
-  font-size: 12px;
-  font-weight: 950;
-}
-
-.rule-application-flow {
-  display: grid;
-  grid-template-columns: repeat(4, minmax(0, 1fr));
-  gap: 8px;
-}
-
-.rule-application-flow > span,
-.rule-application-flow > .badge {
-  min-height: 34px;
-  display: grid;
-  gap: 3px;
-  justify-content: flex-start;
-  padding: 7px 8px;
-}
-
-.drawer-business-content {
-  display: grid;
-  gap: 12px;
-  min-height: 0;
-  overflow: auto;
-}
-
-.drawer-explain-card,
-.drawer-explain-grid > div,
-.raw-details {
-  padding: 12px;
-  border: 1px solid #dfe5ee;
-  border-radius: 8px;
-  background: #fbfcfe;
-}
-
-.drawer-explain-card {
-  display: grid;
-  gap: 8px;
-}
-
-.drawer-explain-card span,
-.drawer-explain-grid span {
-  color: #8491a6;
-  font-size: 11px;
-  font-weight: 900;
-}
-
-.drawer-explain-card strong,
-.drawer-explain-grid strong {
-  color: #172033;
-  font-size: 14px;
-  font-weight: 950;
-}
-
-.drawer-explain-card p,
-.drawer-explain-grid p {
-  margin: 0;
-  color: #65748c;
-  font-size: 12px;
-  font-weight: 800;
-  line-height: 1.55;
-}
-
-.drawer-explain-grid {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 10px;
-}
-
-.drawer-explain-grid > div {
-  display: grid;
-  gap: 6px;
-}
-
-.raw-details summary {
-  color: #22304a;
-  font-size: 13px;
-  font-weight: 900;
-  cursor: pointer;
-}
-
-.raw-details .code-block {
-  margin-top: 10px;
-}
-
-.walk-board {
-  height: 520px;
-  border: 1px solid #dfe5ee;
-  border-radius: 8px;
-  overflow: auto;
-  background: #fbfcfe;
-}
-
-.walk-board-inner {
-  position: relative;
-  width: 980px;
-  min-height: 500px;
-}
-
-.walk-lines {
-  position: absolute;
-  inset: 0;
-  width: 980px;
-  height: 500px;
-  pointer-events: none;
-}
-
-.walk-lines line {
-  stroke: #9cbce8;
-  stroke-width: 2;
-}
-
-.walk-lines text {
-  fill: #65748c;
-  font-size: 11px;
-  font-weight: 900;
-}
-
-.walk-node-card {
-  position: absolute;
-  width: 172px;
-  min-height: 72px;
-  display: grid;
-  align-content: center;
-  gap: 4px;
-  padding: 9px;
-  border: 1px solid #bcd5fb;
-  border-radius: 8px;
-  color: #172033;
-  background: #ffffff;
-  text-align: left;
-  cursor: pointer;
-}
-
-.walk-node-card:hover,
-.walk-edge-chip:hover {
-  border-color: #2360ad;
-  box-shadow: 0 8px 18px rgb(35 96 173 / 12%);
-}
-
-.walk-node-card strong {
-  overflow: hidden;
-  color: #172033;
-  font-size: 12px;
-  font-weight: 950;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.walk-node-card span {
-  color: #65748c;
-  font-size: 10px;
-  font-weight: 850;
-}
-
-.walk-edge-chip {
-  position: absolute;
-  width: 140px;
-  min-height: 26px;
-  border: 1px solid #dfe5ee;
-  border-radius: 8px;
-  color: #2360ad;
-  background: #eaf3ff;
-  font-size: 11px;
-  font-weight: 950;
-  cursor: pointer;
-}
-
-.walk-edge-chip.failed,
-.walk-node-card.failed {
-  color: #9d2020;
-  border-color: #f0bbb8;
-  background: #fff8f7;
-}
-
-.walk-edge-chip.pending,
-.walk-node-card.pending {
-  color: #8a5314;
-  border-color: #f3d295;
-  background: #fff8ea;
-}
-
-.walk-edge-chip.success,
-.walk-node-card.success {
-  color: #12604b;
-  border-color: #bce6d4;
-  background: #f2fbf7;
-}
-
-/* Legacy names kept unused for local screenshots generated before the graph fallback. */
-.flow-node-box {
-  min-width: 150px;
-  padding: 8px;
-  border: 1px solid #bcd5fb;
-  border-radius: 8px;
-  color: #172033;
-  background: #ffffff;
-}
-
-.flow-node {
-  display: grid;
-  gap: 4px;
-}
-
-.flow-node strong {
-  max-width: 170px;
-  overflow: hidden;
-  font-size: 12px;
-  font-weight: 950;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.flow-node span {
-  color: #65748c;
-  font-size: 10px;
-  font-weight: 850;
-}
-
-.walk-board.unused {
-  overflow: hidden;
-  background: #fbfcfe;
-}
-
-.walk-empty-board {
-  min-height: 420px;
-  display: grid;
-  place-items: center;
-  align-content: center;
-  gap: 10px;
-  border: 1px dashed #cbd6e4;
-  border-radius: 8px;
-  color: #65748c;
-  background: #fbfcfe;
-  text-align: center;
-}
-
-.walk-empty-board strong {
-  color: #172033;
-}
-
-.drawer-backdrop {
-  position: fixed;
-  inset: 0;
-  z-index: 50;
-  display: flex;
-  justify-content: flex-end;
-  background: rgb(15 23 42 / 24%);
-}
-
-.technical-drawer {
-  width: min(620px, 92vw);
-  height: 100vh;
-  display: grid;
-  grid-template-rows: auto auto minmax(0, 1fr);
-  gap: 12px;
-  padding: 18px;
-  border-left: 1px solid #dfe5ee;
-  background: #ffffff;
-  box-shadow: -8px 0 28px rgb(15 23 42 / 14%);
-}
-
-.drawer-header {
-  display: flex;
-  align-items: flex-start;
-  justify-content: space-between;
-  gap: 12px;
-}
-
-.drawer-header div {
-  display: grid;
-  gap: 4px;
-}
-
-.drawer-header span {
-  color: #8491a6;
-  font-size: 11px;
-  font-weight: 900;
-}
-
-.drawer-header strong {
-  color: #172033;
-  font-size: 18px;
-  font-weight: 950;
-}
-
-.drawer-tabs {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-}
-
-@media (max-width: 900px) {
-  body {
-    min-width: 0;
-  }
-
-  .workspace {
-    padding: 10px;
-  }
-
-  .main-grid,
-  .stage-layout,
-  .metrics-grid,
-  .fact-grid,
-  .content-meta-grid,
-  .source-summary-grid,
-  .stage-nav-grid,
-  .rule-application-flow {
-    grid-template-columns: 1fr;
-  }
-
-  .filter-bar,
-  .detail-header {
-    align-items: stretch;
-    flex-direction: column;
-  }
-
-  .topbar {
-    grid-template-columns: 1fr;
-    justify-items: stretch;
-  }
-
-  .brand {
-    justify-content: flex-start;
-  }
-
-  .toolbar {
-    justify-content: flex-start;
-    flex-wrap: wrap;
-  }
-
-  .input,
-  .select {
-    width: 100%;
-  }
-}
-
-/* ===== Web V2: 时间线 / 日志 / 配置页 ===== */
-.chip {
-  border: 1px solid var(--line, #d8dce5);
-  background: transparent;
-  border-radius: 999px;
-  padding: 4px 12px;
-  font-size: 12px;
-  cursor: pointer;
-  color: inherit;
-}
-.chip.active {
-  background: #1d4ed8;
-  border-color: #1d4ed8;
-  color: #fff;
-}
-.timeline-strip {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 14px;
-  align-items: center;
-  padding: 10px 14px;
-  margin: 10px 0;
-  border: 1px solid var(--line, #d8dce5);
-  border-radius: 10px;
-  font-size: 12.5px;
-}
-.timeline-strip .strip-warn {
-  color: #a82626;
-  font-weight: 600;
-}
-.timeline-strip .strip-muted {
-  color: #9aa4b5;
-}
-
-/* V3 判定卡:Gemini 判定行 + scorecard 行 */
-.gemini-judge-row {
-  display: flex;
-  flex-wrap: wrap;
-  align-items: center;
-  gap: 10px;
-  margin: 6px 0;
-  padding: 7px 10px;
-  border-radius: 9px;
-  background: #f3f7ff;
-  border: 1px solid #d8e2f5;
-  font-size: 12.5px;
-  color: #2a3a55;
-}
-.gemini-judge-row.quota {
-  background: #fdecec;
-  border-color: #f3c9c9;
-}
-.gemini-judge-tag {
-  font-size: 11px;
-  font-weight: 700;
-  color: #2f5bd0;
-  background: #e3ecff;
-  border-radius: 5px;
-  padding: 1px 7px;
-}
-.gemini-judge-quota {
-  color: #a82626;
-  font-weight: 600;
-}
-.gemini-judge-reason {
-  width: 100%;
-  color: #6b7689;
-  font-size: 11.5px;
-}
-.scorecard-row {
-  display: flex;
-  flex-wrap: wrap;
-  align-items: center;
-  gap: 8px;
-  margin-top: 6px;
-  font-size: 12px;
-}
-.scorecard-label {
-  color: #9aa4b5;
-}
-.scorecard-chip {
-  background: #eef0f4;
-  color: #3a4658;
-  border-radius: 6px;
-  padding: 1px 8px;
-}
-.scorecard-chip.deprecated {
-  color: #9aa4b5;
-  text-decoration: line-through;
-}
-.stage-bars {
-  display: grid;
-  gap: 6px;
-}
-.stage-bar-row {
-  display: grid;
-  grid-template-columns: 200px minmax(80px, 1fr) auto;
-  gap: 10px;
-  align-items: center;
-  font-size: 12.5px;
-}
-.stage-bar-row.failed .stage-bar-fill {
-  background: #dc2626;
-}
-.stage-bar-row.failed .stage-bar-value {
-  color: #dc2626;
-}
-.stage-bar-label {
-  font-family: var(--mono, ui-monospace, monospace);
-}
-.stage-bar-track {
-  background: rgba(125, 135, 155, 0.16);
-  border-radius: 5px;
-  height: 10px;
-  overflow: hidden;
-}
-.stage-bar-fill {
-  display: block;
-  height: 100%;
-  background: #2563eb;
-  border-radius: 5px;
-}
-.event-list {
-  list-style: none;
-  margin: 10px 0 0;
-  padding: 0;
-  display: grid;
-  gap: 8px;
-}
-.event-row {
-  border: 1px solid var(--line, #d8dce5);
-  border-radius: 10px;
-  padding: 8px 12px;
-}
-.event-row.failed {
-  border-color: #dc2626;
-}
-.event-row-head {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 10px;
-  align-items: center;
-  font-size: 12.5px;
-}
-.event-type {
-  font-weight: 600;
-}
-.event-payload {
-  margin: 8px 0 0;
-  padding: 10px;
-  border-radius: 8px;
-  background: rgba(125, 135, 155, 0.1);
-  font-size: 11.5px;
-  overflow-x: auto;
-  white-space: pre-wrap;
-  word-break: break-all;
-}
-.error-state.inline {
-  display: inline;
-  padding: 1px 8px;
-  margin: 0;
-}
-.config-table-wrap {
-  overflow-x: auto;
-}
-.config-table {
-  width: 100%;
-  border-collapse: collapse;
-  font-size: 12px;
-}
-.config-table th,
-.config-table td {
-  border: 1px solid var(--line, #d8dce5);
-  padding: 6px 8px;
-  text-align: left;
-  vertical-align: top;
-  max-width: 360px;
-  word-break: break-all;
-}
-.config-table th {
-  background: rgba(125, 135, 155, 0.1);
-  font-weight: 600;
-}
-
-.config-table th small {
-  display: block;
-  font-weight: 400;
-  font-family: var(--mono, ui-monospace, monospace);
-  font-size: 10px;
-  opacity: 0.65;
-}
-.field-annotations {
-  margin-top: 8px;
-  font-size: 12px;
-}
-.field-annotations dl {
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
-  gap: 6px 18px;
-  margin: 8px 0 0;
-}
-.field-annotations dt {
-  font-weight: 600;
-}
-.field-annotations dt small {
-  margin-left: 6px;
-  font-weight: 400;
-  font-family: var(--mono, ui-monospace, monospace);
-  opacity: 0.65;
-}
-.field-annotations dd {
-  margin: 0 0 4px;
-  color: inherit;
-  opacity: 0.8;
-}
-
-/* 平台/内容帖子卡片:展开/收缩 */
-.content-card-toggle {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  width: 100%;
-  padding: 0;
-  border: none;
-  background: transparent;
-  cursor: pointer;
-  text-align: left;
-  color: inherit;
-}
-.content-card-toggle svg {
-  flex: 0 0 auto;
-  color: #8491a6;
-  transition: color 0.15s;
-}
-.content-card-toggle:hover svg {
-  color: #2563eb;
-}
-.content-card-toggle .content-card-heading {
-  flex: 1 1 auto;
-}
-
-/* 内容帖子展开:左详情 + 右竖屏视频 */
-.content-card-body {
-  display: flex;
-  gap: 16px;
-  flex-wrap: wrap;
-  align-items: flex-start;
-}
-.content-card-detail {
-  flex: 1 1 300px;
-  min-width: 0;
-  display: grid;
-  gap: 10px;
-}
-.content-card-video {
-  flex: 0 0 auto;
-}
-.content-video-frame,
-.content-video-placeholder {
-  width: 270px;
-  height: 480px;
-  border-radius: 12px;
-  border: none;
-}
-.content-video-frame {
-  background: #000;
-  display: block;
-}
-.content-video-placeholder {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  gap: 10px;
-  border: 1px dashed #cdd5e3;
-  background: rgba(125, 135, 155, 0.06);
-  font-size: 13px;
-}
-.content-video-load {
-  cursor: pointer;
-  color: #5b6678;
-  transition: color 0.15s, border-color 0.15s;
-}
-.content-video-load:hover {
-  color: #2563eb;
-  border-color: #2563eb;
-}
-
-/* 游走护栏门控矩阵单元格三色 */
-.config-table td.gate-allow { color: #1d6b3e; background: #e9f7ef; font-weight: 600; }
-.config-table td.gate-low { color: #9a6a0a; background: #fff6e5; font-weight: 600; }
-.config-table td.gate-deny { color: #a82626; background: #fdecec; font-weight: 600; }
-
-/* ===== 内容发现旅程(WalkJourney)===== */
-.walk-journey { display: flex; flex-direction: column; gap: 12px; }
-
-/* ① 找内容(细行) */
-.wj-find-row {
-  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
-  padding: 8px 12px; background: #fafbfd; border: 1px solid #e4e8f0; border-radius: 10px;
-}
-.wj-find-label { font-size: 12px; font-weight: 700; color: #3a4658; margin-right: 4px; }
-.wj-find-chip {
-  display: inline-flex; align-items: center; gap: 6px;
-  font-size: 12.5px; color: #22304a; padding: 4px 10px;
-  background: #ffffff; border: 1px solid #e4e8f0; border-radius: 8px;
-}
-.wj-find-ic { color: #5b6678; display: inline-flex; }
-.wj-find-n { font-size: 11px; font-weight: 600; color: #6b7689; }
-.wj-flow-arrow { display: flex; justify-content: center; color: #b6bfce; margin: 2px 0; }
-
-/* ② 判定焦点卡 */
-.wj-judge-card { border: 1px solid #d8e2f5; border-radius: 12px; background: #ffffff; padding: 14px 16px; }
-.wj-judge-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
-.wj-judge-step { font-size: 11px; color: #9aa4b5; }
-.wj-judge-title { font-size: 17px; font-weight: 700; color: #172033; margin: 3px 0; }
-.wj-judge-sub { font-size: 12.5px; color: #5b6678; }
-.wj-pack-btn {
-  display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
-  font-size: 13px; padding: 8px 12px; border-radius: 8px;
-  border: 1px solid #c9d8f2; background: #eaf3ff; color: #2360ad; cursor: pointer;
-}
-.wj-pack-btn:hover { background: #ddebfe; }
-
-/* 规则包内联体 */
-.wj-rulepack-body { margin-top: 12px; border-top: 1px solid #eef1f6; padding-top: 12px; display: flex; flex-direction: column; gap: 8px; }
-.wj-rule-hint { font-size: 12px; color: #9aa4b5; }
-.wj-rule-tier { display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: #2a3a55; }
-.wj-tier-tag { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 6px; white-space: nowrap; }
-.wj-tier-tag.reject { background: #fdecec; color: #a82626; }
-.wj-tier-tag.ai { background: #fdf0d9; color: #8a5a12; }
-.wj-tier-tag.score { background: #e9f7ef; color: #1d6b3e; }
-.wj-fullrules-btn { align-self: flex-start; }
-.wj-rule-table { width: 100%; border-collapse: collapse; font-size: 12.5px; margin-top: 4px; }
-.wj-rule-table th, .wj-rule-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid #e4e8f0; }
-.wj-rule-table th { color: #9aa4b5; font-weight: 600; }
-
-/* 分叉标签 + 两栏 */
-.wj-branch-labels { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 4px; }
-.wj-branch-labels span { font-size: 12.5px; font-weight: 600; text-align: center; }
-.wj-branch-labels .pass { color: #1d6b3e; }
-.wj-branch-labels .reject { color: #a82626; }
-.wj-branches { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
-.wj-branch { display: flex; flex-direction: column; gap: 8px; }
-.wj-branch-card {
-  width: 100%; text-align: left; border: 0; border-radius: 10px; padding: 11px 13px; cursor: pointer;
-}
-.wj-branch-card.pass { background: #eaf6ef; }
-.wj-branch-card.pass:hover { background: #ddf0e5; }
-.wj-branch-card.reject { background: #fcecec; }
-.wj-branch-card.reject:hover { background: #f9dede; }
-.wj-bc-title { display: flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 600; }
-.wj-branch-card.pass .wj-bc-title { color: #1d6b3e; }
-.wj-branch-card.reject .wj-bc-title { color: #a82626; }
-.wj-bc-ic { display: inline-flex; }
-.wj-bc-n { margin-left: auto; font-size: 11px; font-weight: 600; opacity: 0.8; }
-.wj-bc-desc { font-size: 12px; margin-top: 3px; }
-.wj-branch-card.pass .wj-bc-desc { color: #2e6b46; }
-.wj-branch-card.reject .wj-bc-desc { color: #9a3a3a; }
-
-/* 结果筛选 */
-.wj-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
-.wj-filter-label { font-size: 12px; color: #6b7689; }
-.wj-filter-chip {
-  font-size: 12px; padding: 4px 12px; border-radius: 999px;
-  border: 1px solid #d6dde9; background: #ffffff; color: #3a4658; cursor: pointer;
-}
-.wj-filter-chip.active { border-color: #2360ad; background: #eaf3ff; color: #2360ad; font-weight: 600; }
-
-/* 视频旅程卡 */
-.wj-video-list { display: flex; flex-direction: column; gap: 8px; }
-.wj-video-card { border: 1px solid #e4e8f0; border-radius: 12px; background: #ffffff; overflow: hidden; }
-.wj-video-head {
-  display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
-  padding: 11px 13px; background: transparent; border: 0; cursor: pointer;
-}
-.wj-video-title { flex: 1; font-size: 13.5px; color: #172033; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.wj-result-badge { font-size: 11px; font-weight: 700; padding: 2px 10px; border-radius: 999px; white-space: nowrap; }
-.wj-result-badge.pass { color: #1d6b3e; background: #e9f7ef; }
-.wj-result-badge.review { color: #9a6a0a; background: #fff6e5; }
-.wj-result-badge.reject { color: #a82626; background: #fdecec; }
-.wj-result-badge.muted { color: #5b6678; background: #eef0f4; }
-.wj-journey-steps { padding: 4px 13px 13px; display: flex; flex-direction: column; gap: 8px; border-top: 1px solid #f0f2f6; }
-.wj-step { display: flex; gap: 10px; align-items: flex-start; font-size: 13px; color: #2a3a55; }
-.wj-step-tag { min-width: 40px; font-size: 11px; font-weight: 700; color: #ffffff; background: #9aa4b5; border-radius: 5px; padding: 2px 6px; text-align: center; }
-.wj-reason { color: #5b6678; }
-.wj-outcome.pass { color: #1d6b3e; }
-.wj-outcome.review { color: #9a6a0a; }
-.wj-outcome.reject { color: #a82626; }
-.wj-pack-link {
-  display: inline-flex; align-items: center; gap: 4px; margin-left: 8px;
-  font-size: 12px; color: #2360ad; background: transparent; border: 0; cursor: pointer;
-}
-.wj-detail-btn { align-self: flex-start; margin-top: 2px; }
-
-/* ===== 游走双视图:视图切换 + 地图 + 路径树 ===== */
-.wj-viewbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
-.wj-toggle { display: inline-flex; border: 1px solid #d6dde9; border-radius: 9px; overflow: hidden; background: #f1f4f9; }
-.wj-toggle button {
-  display: inline-flex; align-items: center; gap: 6px;
-  font-size: 13px; padding: 6px 14px; border: 0; background: transparent; color: #5b6678; cursor: pointer;
-}
-.wj-toggle button.active { background: #ffffff; color: #2360ad; font-weight: 600; box-shadow: 0 1px 2px rgba(20,30,55,0.06); }
-
-/* B 地图 */
-.wj-map { border: 1px solid #e4e8f0; border-radius: 12px; background: #fbfcfe; padding: 8px 10px; }
-.wj-map svg { display: block; max-width: 720px; margin: 0 auto; }
-.wj-map-legend {
-  display: flex; flex-wrap: wrap; align-items: center; gap: 14px;
-  padding: 8px 6px 2px; border-top: 1px solid #eef1f6; margin-top: 4px;
-  font-size: 12px; color: #6b7689;
-}
-.wj-map-legend .wm-dot { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
-.wm-dot.pass { background: #1d9e75; }
-.wm-dot.reject { background: #d85a30; }
-.wm-dot.base { background: #5b6678; }
-.wm-dot.off { background: #cbd3df; }
-.wj-map-tally { margin-left: auto; color: #3a4658; font-weight: 600; }
-
-/* A 路径树 */
-.wj-tree { font-size: 13.5px; }
-.wj-tree-root {
-  display: flex; align-items: center; gap: 8px;
-  padding: 9px 12px; background: var(--color-background-secondary, #f1f4f9); border-radius: 9px; color: #3a4658;
-}
-.wj-tree-root strong { color: #172033; }
-.wj-tree-children { border-left: 2px solid #d6dde9; margin-left: 20px; padding-left: 16px; padding-top: 8px; display: flex; flex-direction: column; gap: 10px; }
-.wj-tree-q { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: #22304a; }
-.wj-tree-q strong { color: #172033; }
-.wj-tree-qid { font-size: 11px; color: #9aa4b5; }
-.wj-q-chip { font-size: 11px; padding: 1px 8px; border-radius: 999px; background: #eef0f4; color: #5b6678; }
-.wj-q-chip.tag { background: #f0eaff; color: #6a4ea3; }
-.wj-q-count { margin-left: auto; font-size: 11px; color: #9aa4b5; }
-.wj-tree-vids { border-left: 2px solid #e4e8f0; margin-left: 8px; padding-left: 14px; margin-top: 7px; display: flex; flex-direction: column; gap: 6px; }
-.wj-tree-vid { border: 1px solid #eef1f6; border-radius: 9px; background: #ffffff; }
-.wj-tree-vid-head {
-  display: flex; align-items: center; gap: 7px; width: 100%; text-align: left;
-  padding: 8px 11px; background: transparent; border: 0; cursor: pointer;
-}
-.wj-tree-vid-title { flex: 1; font-size: 13px; color: #172033; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.wj-tree-detail { padding: 2px 11px 11px 30px; display: flex; flex-direction: column; gap: 7px; }
-.wj-tree-loop {
-  display: flex; align-items: center; gap: 8px;
-  padding: 8px 12px; border-radius: 9px; background: #f0eaff; color: #5b4b86; font-size: 12.5px;
-}
-
-/* ===== 路径树:搜索块 + 来历 + 递进嵌套 ===== */
-.wj-blocks { border-left: 2px solid #d6dde9; margin-left: 20px; padding-left: 16px; padding-top: 8px; display: flex; flex-direction: column; gap: 9px; }
-.wj-block { border: 1px solid #e4e8f0; border-radius: 11px; background: #ffffff; transition: box-shadow .2s; }
-.wj-block.wj-hl, .wj-vid-head.wj-hl { box-shadow: 0 0 0 2px #f0b429; border-radius: 11px; }
-.wj-block-head { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; width: 100%; text-align: left; padding: 10px 12px; background: transparent; border: 0; cursor: pointer; }
-.wj-seq { width: 20px; height: 20px; border-radius: 50%; background: #eaf3ff; color: #2360ad; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; }
-.wj-block-title { font-size: 14px; color: #172033; }
-.wj-origin-chip { font-size: 11.5px; padding: 2px 9px; border-radius: 999px; display: inline-flex; align-items: center; gap: 3px; }
-.wj-origin-chip.seed { background: #eef0f4; color: #5b6678; }
-.wj-origin-chip.llm { background: #eaf3ff; color: #2360ad; }
-.wj-origin-chip.tag { background: #f0eaff; color: #6a4ea3; }
-.wj-origin-chip.clickable { cursor: pointer; }
-.wj-origin-chip.clickable:hover { text-decoration: underline; }
-.wj-crumbs { padding: 0 12px 6px 39px; font-size: 11.5px; color: #9aa4b5; display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
-.wj-crumb-link { background: transparent; border: 0; color: #2360ad; cursor: pointer; font-size: 11.5px; padding: 0 1px; }
-.wj-crumb-link:hover { text-decoration: underline; }
-.wj-crumb-text { color: #9aa4b5; }
-.wj-crumb-sep { color: #c2cbd9; vertical-align: -1px; }
-.wj-block-body { padding: 2px 12px 12px 22px; display: flex; flex-direction: column; gap: 7px; }
-.wj-vid-head { display: flex; align-items: center; gap: 7px; width: 100%; text-align: left; padding: 7px 9px; background: #ffffff; border: 1px solid #eef1f6; border-radius: 9px; cursor: pointer; }
-.wj-vid-title { flex: 1; font-size: 13px; color: #172033; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-/* 视频详情:左栏(判定/理由/评分)占大半,右栏竖屏视频+打开原帖 */
-.wj-vid-detail { padding: 6px 9px 6px 28px; display: flex; flex-direction: row; gap: 18px; align-items: flex-start; }
-.wj-vid-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
-.wj-vid-side { flex: 0 0 auto; }
-.wj-vid-acts { display: flex; flex-direction: column; gap: 4px; padding: 3px 0 0 24px; }
-.wj-act { display: flex; align-items: center; gap: 5px; font-size: 12px; }
-.wj-act.pass { color: #1d6b3e; }
-.wj-act.fwd { color: #6a4ea3; background: transparent; border: 0; cursor: pointer; text-align: left; padding: 0; }
-.wj-act.fwd:hover { text-decoration: underline; }
-.wj-act.author { display: block; }
-.wj-act-head { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #155a86; }
-.wj-author-works { border-left: 2px solid #cfe0f0; margin-left: 7px; padding-left: 12px; margin-top: 5px; display: flex; flex-direction: column; gap: 5px; }
-
-/* 翻页结构化:分隔行 + 翻页页视频淡底 */
-.wj-page-divider {
-  display: flex; align-items: center; gap: 6px;
-  margin: 4px 0 2px; padding: 3px 10px;
-  font-size: 11.5px; color: #6b7689;
-  background: #f1f4f9; border-radius: 6px; width: fit-content;
-}
-.wj-page-group > .wj-vid > .wj-vid-head { background: #f7f9fc; border-color: #e4e8f0; }
-
-/* ===== 激进合一:漏斗总览条 + 5 面板导航 ===== */
-.funnel-strip {
-  display: flex; flex-wrap: wrap; align-items: center; gap: 2px;
-  padding: 10px 14px; margin-bottom: 10px;
-  border: 1px solid #dfe5ee; border-radius: 10px; background: #ffffff;
-}
-.funnel-seg-wrap { display: inline-flex; align-items: center; }
-.funnel-arrow { color: #c2cbd9; margin: 0 2px; }
-.funnel-seg {
-  display: flex; flex-direction: row; align-items: center; gap: 6px;
-  padding: 4px 9px; border: 1px solid transparent; border-radius: 8px;
-  background: transparent; cursor: pointer;
-}
-.funnel-seg:hover { background: #f4f7fb; border-color: #e0e6f0; }
-.funnel-label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: #5d6b82; font-weight: 600; white-space: nowrap; }
-.funnel-count { font-size: 15px; font-weight: 900; color: #172033; }
-.funnel-dot { width: 7px; height: 7px; border-radius: 50%; background: #b4bccb; }
-.funnel-dot.success { background: #2e9b5b; }
-.funnel-dot.failed, .funnel-dot.rule_blocked { background: #dc4040; }
-.funnel-dot.pending, .funnel-dot.partial_success { background: #e0a020; }
-
-.panel-nav {
-  display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-bottom: 10px;
-}
-.panel-nav-card {
-  display: flex; flex-direction: column; gap: 4px; min-height: 58px;
-  padding: 11px 13px; text-align: left; cursor: pointer;
-  border: 1px solid #dfe5ee; border-radius: 9px; background: #ffffff;
-}
-.panel-nav-card:hover { border-color: #b9c4d6; background: #fafbfd; }
-.panel-nav-card.active { border-color: #2360ad; background: #eaf3ff; box-shadow: inset 0 0 0 1px #d8e8ff; }
-.panel-nav-top strong { font-size: 14px; font-weight: 900; color: #172033; }
-.panel-nav-card.active .panel-nav-top strong { color: #2360ad; }
-.panel-nav-sub { font-size: 11.5px; color: #8491a6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-
-/* 旅程视频详情:播放器 + AI 扩写提示词入口 */
-.wj-vid-media { margin: 0; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
-.wj-vid-media .content-card-video { margin: 0; }
-.wj-media-origin { align-self: flex-start; }
-.wj-prompt-link { color: #2360ad; font-weight: 600; }
-.wj-prompt-link:hover { text-decoration: underline; }
-
-/* ===== 策略配置面板(第 5 面板)===== */
-.strategy-config { display: flex; flex-direction: column; gap: 14px; }
-.cfg-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
-.cfg-top-note { font-size: 12.5px; margin: 0; max-width: 720px; }
-.cfg-block { border: 1px solid #e4e8f0; border-radius: 14px; background: #ffffff; padding: 14px 16px; box-shadow: 0 1px 2px rgba(20,39,66,.04); }
-.cfg-block-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid #eef1f6; }
-.cfg-block-titles { display: flex; flex-direction: column; gap: 3px; }
-.cfg-block-head strong { display: inline-flex; align-items: center; gap: 7px; font-size: 15px; font-weight: 900; color: #172033; }
-.cfg-block-sub { font-size: 12px; color: #8491a6; font-weight: 600; }
-.cfg-edit-hint { display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; color: #8a5a12; background: #fdf0d9; border-radius: 6px; padding: 3px 9px; white-space: nowrap; }
-
-/* 通用子段标题 */
-.cfg-sub { margin-bottom: 14px; }
-.cfg-sub:last-child { margin-bottom: 0; }
-.cfg-sub-title { font-size: 12.5px; font-weight: 900; color: #3f4b61; margin-bottom: 8px; padding-left: 8px; border-left: 3px solid #cdd9ec; }
-
-/* 业务动作色板(入池/复看/淘汰/中性) */
-.cfg-act { font-size: 11.5px; font-weight: 900; padding: 2px 10px; border-radius: 999px; white-space: nowrap; }
-.cfg-act.pool { color: #12805a; background: #e2f6ea; border: 1px solid #bfe6cf; }
-.cfg-act.review { color: #9a6512; background: #fff2d7; border: 1px solid #f0dca6; }
-.cfg-act.reject { color: #ad2e23; background: #fdeceb; border: 1px solid #f1c9c5; }
-.cfg-act.muted { color: #5d6b82; background: #eef2f7; border: 1px solid #e0e6f0; }
-
-/* ① Prompt 原文 */
-.cfg-prompt { display: flex; flex-direction: column; gap: 12px; }
-.cfg-prompt-profile { display: flex; flex-direction: column; gap: 10px; }
-.cfg-prompt-name { font-size: 12px; font-weight: 800; color: #2360ad; }
-.cfg-prompt-block { display: flex; flex-direction: column; gap: 5px; }
-.cfg-prompt-tag { font-size: 11.5px; font-weight: 900; color: #5a4ba8; }
-.cfg-prompt-text {
-  margin: 0; padding: 12px 14px; border-radius: 10px; border: 1px solid #e6e3f5;
-  background: #faf9ff; color: #2c3550; font-size: 12.5px; line-height: 1.7;
-  white-space: pre-wrap; word-break: break-word; font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
-}
-.cfg-hint-line { margin: 0; font-size: 12px; color: #8491a6; }
-.cfg-hint-line code { background: #eef2f7; border-radius: 4px; padding: 1px 5px; font-size: 11.5px; color: #5d6b82; }
-
-/* ② 判定规则 */
-.cfg-judge { display: flex; flex-direction: column; }
-.cfg-gate-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
-.cfg-gate-row {
-  display: flex; align-items: center; justify-content: space-between; gap: 10px;
-  padding: 7px 11px; border: 1px solid #eef1f6; border-radius: 9px; background: #fbfcfe;
-}
-.cfg-gate-label { font-size: 12.5px; font-weight: 700; color: #3f4b61; }
-.cfg-score-bars { display: flex; flex-direction: column; gap: 9px; }
-.cfg-score-item { display: flex; flex-direction: column; gap: 4px; }
-.cfg-score-top { display: flex; align-items: center; justify-content: space-between; font-size: 12.5px; font-weight: 800; color: #3f4b61; }
-.cfg-score-top strong { color: #172033; font-weight: 950; }
-.cfg-score-bar { height: 9px; border-radius: 999px; background: #eef2f7; overflow: hidden; }
-.cfg-score-bar span { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #4f8ee0, #2360ad); }
-.cfg-band-row { display: flex; gap: 8px; }
-.cfg-band {
-  flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px;
-  padding: 10px 8px; border-radius: 10px; border: 1px solid #e0e6f0;
-}
-.cfg-band-range { font-size: 12px; font-weight: 800; color: #6d7c93; }
-.cfg-band-act { font-size: 15px; font-weight: 950; }
-.cfg-band.pool { background: #f0fbf4; border-color: #bfe6cf; }
-.cfg-band.pool .cfg-band-act { color: #12805a; }
-.cfg-band.review { background: #fff9ec; border-color: #f0dca6; }
-.cfg-band.review .cfg-band-act { color: #9a6512; }
-.cfg-band.reject { background: #fdf1f0; border-color: #f1c9c5; }
-.cfg-band.reject .cfg-band-act { color: #ad2e23; }
-
-/* ③ 游走规则 */
-.cfg-walk { display: flex; flex-direction: column; }
-.cfg-walk-note { margin: 0 0 9px; font-size: 12px; line-height: 1.6; color: #6d7c93; }
-
-/* A. 起点判断:判定结果 → 各游走放行 */
-.cfg-perm-table { display: flex; flex-direction: column; gap: 5px; }
-.cfg-perm-head, .cfg-perm-row { display: grid; grid-template-columns: 96px repeat(2, minmax(0, 1fr)); gap: 8px; align-items: center; }
-.cfg-perm-head span { font-size: 11.5px; font-weight: 800; color: #8491a6; text-align: center; }
-.cfg-perm-head span:first-child { text-align: left; }
-.cfg-perm-row { padding: 6px 8px; border: 1px solid #eef1f6; border-radius: 9px; background: #fbfcfe; }
-.cfg-perm-row > .cfg-act { justify-self: start; }
-.cfg-perm-cell { text-align: center; font-size: 12px; font-weight: 800; padding: 4px 6px; border-radius: 7px; }
-.cfg-perm-cell.pool { color: #12805a; background: #e9f8f0; }
-.cfg-perm-cell.review { color: #9a6512; background: #fdf3de; }
-.cfg-perm-cell.reject { color: #b0746d; background: #f6eceb; }
-
-/* B. 三种动作卡 */
-.cfg-walk-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
-.cfg-walk-card { display: flex; flex-direction: column; gap: 6px; padding: 12px; border: 1px solid #e4e9f2; border-radius: 11px; background: #fbfcfe; }
-.cfg-walk-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
-.cfg-walk-card-head strong { font-size: 13.5px; font-weight: 900; color: #172033; }
-.cfg-walk-desc { margin: 0; font-size: 12px; line-height: 1.55; color: #6d7c93; }
-.cfg-walk-trigger { font-size: 11.5px; font-weight: 700; color: #5a4ba8; background: #f2effb; border-radius: 7px; padding: 4px 8px; }
-.cfg-walk-limits { display: flex; flex-wrap: wrap; gap: 6px; }
-.cfg-walk-limits span { font-size: 11.5px; font-weight: 700; color: #4a566e; background: #eef2f7; border-radius: 6px; padding: 2px 8px; }
-
-/* C. 拆标签后的链路 + 刹车 */
-.cfg-walk-chain { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 9px; }
-.cfg-chain-step { font-size: 12px; font-weight: 700; color: #2c3550; background: #eef3fb; border: 1px solid #d8e3f3; border-radius: 8px; padding: 5px 10px; }
-.cfg-chain-arrow { color: #9aa6ba; font-weight: 900; }
-.cfg-walk-brakes { display: flex; flex-wrap: wrap; gap: 7px; }
-.cfg-walk-brakes span { font-size: 11.5px; font-weight: 700; color: #8a5a12; background: #fdf3de; border: 1px solid #f0e2bf; border-radius: 999px; padding: 3px 11px; }
-.cfg-walk-brakes b { color: #6b4709; font-weight: 950; }
-
-/* ===== 顶部压缩(中度合并):平台/耗时上提顶栏,概览带只剩单行漏斗 + 单行 tab ===== */
-.run-overview {
-  display: flex; align-items: center; gap: 10px;
-  padding: 3px 10px; margin-bottom: 8px;
-  border: 1px solid #dfe5ee; border-radius: 10px; background: #ffffff;
-}
-.run-overview .funnel-strip { flex: 1; border: 0; padding: 0; margin: 0; background: transparent; flex-wrap: nowrap; overflow-x: auto; }
-/* 平台 chip + 运行元信息:现在挂在顶栏右侧工具区 */
-.run-platform-chip {
-  display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
-  font-size: 12px; font-weight: 700; color: #2360ad;
-  background: #eaf3ff; border: 1px solid #cfe0f7; border-radius: 999px; padding: 4px 11px;
-}
-.run-meta-inline {
-  display: inline-flex; align-items: center; gap: 10px; flex-wrap: nowrap;
-  font-size: 12px; color: #6b7689; white-space: nowrap;
-}
-.run-meta-inline .strip-warn { color: #a82626; font-weight: 600; }
-
-.panel-nav { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
-.panel-nav-tab {
-  font-size: 13px; font-weight: 700; padding: 7px 16px; cursor: pointer;
-  border: 1px solid #dfe5ee; border-radius: 8px; background: #ffffff; color: #5d6b82;
-}
-.panel-nav-tab:hover { border-color: #b9c4d6; background: #fafbfd; }
-.panel-nav-tab.active { border-color: #2360ad; background: #eaf3ff; color: #2360ad; box-shadow: inset 0 0 0 1px #d8e8ff; }
-
-/* ===== 内容资产「成品货架」 ===== */
-.asset-shelf { display: flex; flex-direction: column; gap: 14px; }
-
-/* 交付摘要条 */
-.asset-summary {
-  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
-  padding: 12px 14px; border: 1px solid #e4e9f2; border-radius: 12px;
-  background: linear-gradient(180deg, #fbfcfe 0%, #f6f9fd 100%);
-}
-.asset-summary-stat {
-  display: flex; flex-direction: column; gap: 2px; align-items: center;
-  min-width: 92px; padding: 6px 14px; border-radius: 10px; border: 1px solid #e4e9f2; background: #ffffff;
-}
-.asset-summary-stat span { font-size: 11px; font-weight: 800; color: #8491a6; }
-.asset-summary-stat strong { font-size: 22px; font-weight: 950; color: #172033; line-height: 1; }
-.asset-summary-stat.pooled { border-color: #bfe6cf; background: #f0fbf4; }
-.asset-summary-stat.pooled strong { color: #12805a; }
-.asset-summary-stat.review { border-color: #f3dcad; background: #fff9ec; }
-.asset-summary-stat.review strong { color: #9a6512; }
-.asset-summary-stat.reject { border-color: #f1c9c5; background: #fdf1f0; }
-.asset-summary-stat.reject strong { color: #ad2e23; }
-.asset-summary-note { margin: 0 0 0 auto; font-size: 12px; color: #7a8699; max-width: 360px; text-align: right; }
-
-/* 卡片列表(每行一卡,左视频右信息) */
-.asset-shelf-list { display: flex; flex-direction: column; gap: 12px; }
-.asset-card {
-  display: flex; gap: 16px; align-items: flex-start;
-  padding: 14px; border: 1px solid #e4e9f2; border-radius: 14px; background: #ffffff;
-  box-shadow: 0 1px 2px rgba(20, 39, 66, 0.04); transition: box-shadow 0.15s, border-color 0.15s;
-}
-.asset-card:hover { border-color: #cdd9ec; box-shadow: 0 4px 14px rgba(20, 39, 66, 0.08); }
-
-/* 左:竖屏视频 */
-.asset-card-media { flex: 0 0 auto; }
-.asset-media-frame {
-  width: 158px; height: 281px; border-radius: 12px; border: none; display: block; background: #000;
-}
-.asset-media-play {
-  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
-  color: #5d6b82; background: rgba(125, 135, 155, 0.07); border: 1px dashed #cdd5e3; cursor: pointer;
-  font-size: 13px; font-weight: 800;
-}
-.asset-media-play:hover { color: #2360ad; border-color: #9cbce8; background: #f4f9ff; }
-.asset-media-empty {
-  display: flex; align-items: center; justify-content: center; text-align: center; padding: 10px;
-  background: rgba(125, 135, 155, 0.06); border: 1px dashed #cdd5e3; font-size: 12px;
-}
-
-/* 右:信息区 */
-.asset-card-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
-.asset-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
-.asset-card-title {
-  font-size: 15px; font-weight: 900; color: #172033; line-height: 1.4;
-  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
-}
-.asset-status {
-  flex: 0 0 auto; font-size: 11px; font-weight: 900; padding: 3px 10px; border-radius: 999px; white-space: nowrap;
-}
-.asset-status.pooled { color: #12805a; background: #e2f6ea; border: 1px solid #bfe6cf; }
-.asset-card-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; font-weight: 700; color: #6d7c93; }
-.asset-from { color: #2360ad; }
-
-/* Gemini 判定行 */
-.asset-judge { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; }
-.asset-judge-tag { display: inline-flex; align-items: center; gap: 4px; font-weight: 900; color: #5a4ba8; }
-.asset-judge-fit { font-weight: 900; padding: 2px 9px; border-radius: 999px; }
-.asset-judge-fit.ok { color: #12805a; background: #e2f6ea; }
-.asset-judge-fit.no { color: #ad2e23; background: #fdeceb; }
-.asset-judge-num { color: #6d7c93; font-weight: 700; }
-.asset-judge-score {
-  margin-left: auto; font-weight: 950; color: #172033;
-  padding: 2px 10px; border-radius: 8px; background: #eef2f8; border: 1px solid #e0e6f0;
-}
-
-.asset-reason {
-  margin: 0; font-size: 12.5px; line-height: 1.6; color: #4a566e;
-  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
-}
-
-/* 抖音热度 */
-.asset-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
-.asset-stats-label { font-size: 11px; font-weight: 900; color: #8491a6; }
-.asset-stat { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 800; color: #51607a; }
-.asset-stat svg { color: #9aa6ba; }
-.asset-stat em { font-style: normal; font-weight: 700; color: #8491a6; }
-
-/* 标签 */
-.asset-tags { display: flex; flex-wrap: wrap; gap: 6px; }
-.asset-tag { font-size: 11px; font-weight: 700; color: #5d6b82; background: #f1f4f9; border-radius: 6px; padding: 2px 8px; }
-
-.asset-actions { display: flex; gap: 8px; }
-
-/* 预留:我方平台实测表现 */
-.asset-perf {
-  margin-top: 2px; padding: 10px 12px; border-radius: 10px;
-  border: 1px dashed #cfd8e6; background: repeating-linear-gradient(135deg, #fafbfd, #fafbfd 8px, #f5f7fb 8px, #f5f7fb 16px);
-}
-.asset-perf-head { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 900; color: #6a7488; }
-.asset-perf-flag {
-  font-size: 10px; font-weight: 900; color: #8a7322; background: #fdf3d6; border: 1px solid #f0dca6;
-  border-radius: 999px; padding: 1px 8px; margin-left: 2px;
-}
-.asset-perf-metrics { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
-.asset-perf-cell {
-  display: flex; flex-direction: column; align-items: center; gap: 1px; min-width: 64px;
-  padding: 5px 8px; border-radius: 8px; background: rgba(255,255,255,0.7); border: 1px solid #e6ebf3;
-}
-.asset-perf-cell em { font-style: normal; font-size: 10.5px; font-weight: 800; color: #9aa6ba; }
-.asset-perf-cell b { font-size: 15px; font-weight: 950; color: #b8c2d2; }
-.asset-perf-note { margin: 0; font-size: 11.5px; line-height: 1.5; color: #8491a6; }
-
-/* ===== 内容资产:入池/待复看 切换按钮 ===== */
-.asset-tabbtn {
-  display: flex; flex-direction: column; gap: 2px; align-items: center; cursor: pointer;
-  min-width: 100px; padding: 7px 16px; border-radius: 10px; border: 1px solid #e4e9f2; background: #ffffff;
-}
-.asset-tabbtn span { font-size: 11px; font-weight: 800; color: #8491a6; }
-.asset-tabbtn strong { font-size: 22px; font-weight: 950; color: #172033; line-height: 1; }
-.asset-tabbtn:hover { border-color: #b9c4d6; }
-.asset-tabbtn.pooled.active { border-color: #79cfa1; background: #ecfaf2; box-shadow: inset 0 0 0 1px #bfe6cf; }
-.asset-tabbtn.pooled.active strong { color: #12805a; }
-.asset-tabbtn.review.active { border-color: #ecc977; background: #fff7e8; box-shadow: inset 0 0 0 1px #f3dcad; }
-.asset-tabbtn.review.active strong { color: #9a6512; }
-.asset-summary-reject { font-size: 12px; font-weight: 800; color: #9aa6ba; }
-
-/* 待复看状态徽章 */
-.asset-status.review { color: #9a6512; background: #fff2d7; border: 1px solid #f0dca6; }
-
-/* 「为何待复看」行 */
-.asset-why {
-  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
-  padding: 7px 10px; border-radius: 9px; background: #fff8ec; border: 1px solid #f2e0b8;
-}
-.asset-why-tag { font-size: 11px; font-weight: 900; color: #9a6512; }
-.asset-why-reason { font-size: 12.5px; font-weight: 800; color: #7a5410; }
-.asset-why-rule { font-size: 11px; font-weight: 700; color: #97591a; background: #fdeccb; border-radius: 6px; padding: 2px 8px; }
-
-.asset-judge-score.missing { color: #9a6512; background: #fff2d7; border-color: #f0dca6; }
-.asset-reason.err { color: #ad2e23; }
-
-/* 复看裁决预留块 */
-.asset-perf.review { background: repeating-linear-gradient(135deg, #fffaf0, #fffaf0 8px, #fdf3e0 8px, #fdf3e0 16px); border-color: #ecd9b0; }
-.asset-verdict-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
-.asset-verdict-btn {
-  font-size: 12px; font-weight: 800; padding: 5px 14px; border-radius: 8px;
-  border: 1px dashed #cfd8e6; color: #9aa6ba; background: rgba(255,255,255,0.7); cursor: not-allowed;
-}
-.asset-verdict-btn.pool { border-color: #bfe0cd; color: #5a9d7c; }
-.asset-verdict-btn.reject { border-color: #e6c2bd; color: #b07c75; }
-
-/* ===== /runs 列表卡:需求名为主、可选中 ===== */
-.run-card-demand { font-size: 14.5px; font-weight: 950; color: #172033; line-height: 1.35; }
-.run-card.active .run-card-demand { color: #2360ad; }
-.run-card-id { font-family: "SFMono-Regular", ui-monospace, Menlo, monospace; font-size: 11px; color: #9aa6ba; }
-
-/* ===== /runs 右侧:需求说明书 ===== */
-.demand-detail { display: flex; flex-direction: column; gap: 14px; }
-.demand-head { display: flex; flex-direction: column; gap: 7px; padding-bottom: 14px; border-bottom: 1px solid #eef1f6; }
-.demand-eyebrow { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 900; color: #2360ad; }
-.demand-name { margin: 0; font-size: 23px; font-weight: 950; color: #172033; line-height: 1.25; }
-.demand-desc { margin: 0; font-size: 13px; line-height: 1.65; color: #51607a; }
-.demand-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 2px; }
-.demand-chip { font-size: 12px; font-weight: 800; color: #2360ad; background: #eaf3ff; border: 1px solid #cfe0f7; border-radius: 999px; padding: 3px 11px; }
-.demand-chip.ghost { color: #5d6b82; background: #f1f4f9; border-color: #e0e6f0; }
-.demand-runid { font-family: "SFMono-Regular", ui-monospace, Menlo, monospace; font-size: 11px; color: #9aa6ba; margin-left: auto; }
-
-.demand-card { border: 1px solid #e4e9f2; border-radius: 13px; background: #ffffff; padding: 14px 16px; }
-.demand-card-title { display: inline-flex; align-items: center; gap: 7px; margin: 0 0 12px; font-size: 14px; font-weight: 900; color: #172033; }
-
-/* 凭什么有这个需求:种子词(Pattern)+ 命中分类(分类树)+ 证据规模 */
-.demand-evidence { display: flex; flex-direction: column; gap: 12px; }
-.demand-ev-block { display: flex; flex-direction: column; gap: 7px; }
-.demand-ev-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
-.demand-tag { font-size: 11.5px; font-weight: 900; padding: 3px 10px; border-radius: 7px; }
-.demand-tag.pattern { color: #5a4ba8; background: #f2effb; border: 1px solid #ddd6f3; }
-.demand-tag.tree { color: #146a52; background: #e8f6f0; border: 1px solid #c4e6d8; }
-.demand-pid { font-size: 11px; font-weight: 800; color: #8491a6; }
-.demand-seed-terms { display: flex; flex-wrap: wrap; gap: 7px; }
-.demand-seed-term { font-size: 13px; font-weight: 900; color: #2c3550; background: #eef3fb; border: 1px solid #d8e3f3; border-radius: 8px; padding: 4px 12px; }
-.demand-tip { margin: 0; font-size: 11.5px; line-height: 1.55; color: #8491a6; }
-.demand-cat-list { display: flex; flex-direction: column; gap: 6px; }
-.demand-cat-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 11px; border: 1px solid #eef1f6; border-radius: 10px; background: #fbfcfe; }
-.demand-cat-name { font-size: 13px; font-weight: 900; color: #172033; white-space: nowrap; }
-.demand-cat-path { flex: 1; min-width: 200px; font-family: "SFMono-Regular", ui-monospace, Menlo, monospace; font-size: 11.5px; color: #51607a; word-break: break-all; }
-.demand-cat-meta { font-size: 11px; font-weight: 800; color: #146a52; background: #e8f6f0; border-radius: 6px; padding: 2px 8px; white-space: nowrap; }
-.demand-meta-row { display: flex; flex-wrap: wrap; gap: 14px; font-size: 12px; color: #6d7c93; padding-top: 2px; }
-.demand-meta-row b { color: #2c3550; font-weight: 900; }
-
-.demand-yield { display: flex; gap: 10px; }
-.demand-yield-stat { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 12px 8px; border-radius: 11px; border: 1px solid #e0e6f0; }
-.demand-yield-stat span { font-size: 11.5px; font-weight: 800; color: #6d7c93; }
-.demand-yield-stat strong { font-size: 24px; font-weight: 950; line-height: 1; }
-.demand-yield-stat.pool { background: #f0fbf4; border-color: #bfe6cf; }
-.demand-yield-stat.pool strong { color: #12805a; }
-.demand-yield-stat.review { background: #fff9ec; border-color: #f0dca6; }
-.demand-yield-stat.review strong { color: #9a6512; }
-.demand-yield-stat.reject { background: #fdf1f0; border-color: #f1c9c5; }
-.demand-yield-stat.reject strong { color: #ad2e23; }
-
-.demand-enter {
-  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
-  padding: 12px 16px; border-radius: 11px; border: 1px solid #2360ad;
-  background: #2360ad; color: #ffffff; font-size: 13.5px; font-weight: 900; cursor: pointer;
-}
-.demand-enter:hover { background: #1d5396; border-color: #1d5396; }

+ 0 - 19
web/app/layout.tsx

@@ -1,19 +0,0 @@
-import type { Metadata } from "next";
-import "./globals.css";
-
-export const metadata: Metadata = {
-  title: "Content Find Agent",
-  description: "Content Find Agent V1 run dashboard"
-};
-
-export default function RootLayout({
-  children
-}: Readonly<{
-  children: React.ReactNode;
-}>) {
-  return (
-    <html lang="zh-CN">
-      <body>{children}</body>
-    </html>
-  );
-}

+ 0 - 5
web/app/page.tsx

@@ -1,5 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default function HomePage() {
-  redirect("/runs");
-}

+ 0 - 10
web/app/runs/[runId]/page.tsx

@@ -1,10 +0,0 @@
-import { RunDashboardPage } from "@/features/runs/RunDashboardPage";
-
-type Props = {
-  params: Promise<{ runId: string }>;
-};
-
-export default async function RunDetailPage({ params }: Props) {
-  const { runId } = await params;
-  return <RunDashboardPage runId={runId} />;
-}

+ 0 - 6
web/app/runs/[runId]/timeline/page.tsx

@@ -1,6 +0,0 @@
-import { RunTimelinePage } from "@/features/runs/RunTimelinePage";
-
-export default async function Page({ params }: { params: Promise<{ runId: string }> }) {
-  const { runId } = await params;
-  return <RunTimelinePage runId={runId} />;
-}

+ 0 - 7
web/app/runs/page.tsx

@@ -1,7 +0,0 @@
-"use client";
-
-import { RunListPage } from "@/features/runs/RunListPage";
-
-export default function RunsPage() {
-  return <RunListPage />;
-}

+ 0 - 29
web/components/badges/StatusBadge.tsx

@@ -1,29 +0,0 @@
-import { Circle, Database, FileJson } from "lucide-react";
-import type { DataOrigin } from "@/lib/api/types";
-import { statusLabel } from "@/lib/status/status";
-
-export function StatusBadge({ status }: { status: unknown }) {
-  const value = String(status || "unknown");
-  return (
-    <span className={`badge ${value}`}>
-      <Circle size={10} fill="currentColor" />
-      {statusLabel(value)}
-    </span>
-  );
-}
-
-export function DataOriginBadge({ origin }: { origin: DataOrigin | string }) {
-  const label =
-    origin === "production_db"
-      ? "生产事实"
-      : origin === "mixed_with_runtime_export"
-        ? "混合回放"
-        : "回放导出";
-  const Icon = origin === "production_db" ? Database : FileJson;
-  return (
-    <span className={`badge ${origin}`}>
-      <Icon size={13} />
-      {label}
-    </span>
-  );
-}

+ 0 - 14
web/components/cards/FactGrid.tsx

@@ -1,14 +0,0 @@
-import { compactValue } from "@/lib/status/status";
-
-export function FactGrid({ rows }: { rows: Array<[string, unknown]> }) {
-  return (
-    <div className="fact-grid">
-      {rows.map(([label, value]) => (
-        <div className="fact" key={label}>
-          <span>{label}</span>
-          <strong>{compactValue(value)}</strong>
-        </div>
-      ))}
-    </div>
-  );
-}

+ 0 - 8
web/components/cards/MetricCard.tsx

@@ -1,8 +0,0 @@
-export function MetricCard({ label, value }: { label: string; value: unknown }) {
-  return (
-    <div className="metric">
-      <span>{label}</span>
-      <strong>{String(value ?? 0)}</strong>
-    </div>
-  );
-}

+ 0 - 31
web/components/cards/RecordList.tsx

@@ -1,31 +0,0 @@
-import { compactValue } from "@/lib/status/status";
-
-export function RecordList({
-  items,
-  empty,
-  primaryKey = "id",
-  fields
-}: {
-  items: Array<Record<string, unknown>>;
-  empty: string;
-  primaryKey?: string;
-  fields: string[];
-}) {
-  if (!items.length) {
-    return <div className="empty-state">{empty}</div>;
-  }
-  return (
-    <div className="record-list">
-      {items.slice(0, 20).map((item, index) => (
-        <div className="record-row" key={String(item[primaryKey] || index)}>
-          <strong>{compactValue(item[primaryKey] || item.title || item.search_query || `#${index + 1}`)}</strong>
-          {fields.map((field) => (
-            <span key={field}>
-              {field}: {compactValue(item[field])}
-            </span>
-          ))}
-        </div>
-      ))}
-    </div>
-  );
-}

+ 0 - 59
web/components/layout/AppShell.tsx

@@ -1,59 +0,0 @@
-import Link from "next/link";
-import { ArrowLeft, RefreshCw } from "lucide-react";
-
-export function AppShell({
-  children,
-  onRefresh,
-  showBack,
-  onBack,
-  backLabel = "返回",
-  toolbarLeading,
-  centerNav
-}: {
-  children: React.ReactNode;
-  onRefresh?: () => void;
-  showBack?: boolean;
-  // 提供 onBack 时,返回按钮走该回调(如浏览器历史回退),回到来时的页面;否则默认 Link 到 /runs。
-  onBack?: () => void;
-  backLabel?: string;
-  toolbarLeading?: React.ReactNode;
-  centerNav?: React.ReactNode;
-}) {
-  return (
-    <main className="app-shell">
-      <div className="workspace">
-        <header className="topbar">
-          <div className="topbar-left">
-            {onBack ? (
-              <button className="back-link" type="button" onClick={onBack}>
-                <ArrowLeft size={15} />
-                {backLabel}
-              </button>
-            ) : showBack ? (
-              <Link className="back-link" href="/runs">
-                <ArrowLeft size={15} />
-                {backLabel}
-              </Link>
-            ) : null}
-            <Link className="brand" href="/runs">
-              <strong>ContentFindAgent</strong>
-            </Link>
-          </div>
-          {centerNav ? <div className="topbar-center">{centerNav}</div> : null}
-          <div className="toolbar">
-            {toolbarLeading}
-            <Link className="back-link" href="/config">
-              配置
-            </Link>
-            {onRefresh ? (
-              <button className="icon-button" onClick={onRefresh} type="button" title="刷新">
-                <RefreshCw size={16} />
-              </button>
-            ) : null}
-          </div>
-        </header>
-        {children}
-      </div>
-    </main>
-  );
-}

+ 0 - 29
web/components/pipeline/PipelineHeader.tsx

@@ -1,29 +0,0 @@
-import type { PipelineStage } from "@/lib/adapters/pipeline";
-
-export function PipelineHeader({
-  stages,
-  activeStage,
-  onSelect
-}: {
-  stages: PipelineStage[];
-  activeStage: string;
-  onSelect: (stageId: string) => void;
-}) {
-  return (
-    <nav className="pipeline-header" aria-label="pipeline">
-      <div className="pipeline-grid">
-        {stages.map((stage) => (
-          <button
-            key={stage.id}
-            className={`pipeline-step ${stage.status} ${activeStage === stage.id ? "active" : ""}`}
-            onClick={() => onSelect(stage.id)}
-            type="button"
-          >
-            <span>{stage.label}</span>
-            {typeof stage.count === "number" ? <span className="badge">{stage.count}</span> : null}
-          </button>
-        ))}
-      </div>
-    </nav>
-  );
-}

+ 0 - 333
web/docs/01_产品功能规划.md

@@ -1,333 +0,0 @@
-# Web 产品功能规划
-
-> 实施现状(2026-06-10):本规划的 V1 范围(Run 列表/总览/七阶段/调试页)已落地(commit 6ee0079 后端 + c6c5e39 前端)。Web V2 三件套(时间线与耗时事实可视化 / 配置只读视图 / 回放来源标注)见 `03_技术开发规划.md` Web V2 节与 `06_WebV2迭代计划.md`。
-
-## 目标用户
-
-新 Web 面向内部运营、产品、策略和工程用户。它不是营销页面,也不是单纯日志页,而是 ContentFindAgent 的运行观察、结果复盘和问题诊断面板。
-
-用户打开 Web 后,应能完成四件事:
-
-1. 找到一次真实 run。
-2. 看懂这次 run 从数据源到策略学习的完整链路。
-3. 看清每个 Query、内容、规则、游走动作为什么成功、失败、待复看或被规则阻断。
-4. 区分生产事实和回放导出,避免把静态样例当成真实结果。
-
-## 总体信息架构
-
-视觉结构参考旧 `show`:
-
-- 顶部 7 阶段 Pipeline:
-  - 数据源
-  - Query
-  - Platform
-  - 判断
-  - 游走
-  - 资产清洗沉淀
-  - 策略学习
-- 页面主体:
-  - 左侧列表 / 筛选区
-  - 右侧详情区
-  - 阶段内表格、卡片、抽屉和 trace strip
-- 状态标识:
-  - 生产事实
-  - 回放导出
-  - 缺失数据
-  - 失败诊断
-  - 部分成功
-
-新 Web 保留 `show` 的视觉心智,但功能范围以当前 V1 真实链路为准。
-
-## 页面规划
-
-### 1. Run 列表页
-
-用途:选择一次运行,并快速判断运行是否可复盘。
-
-核心信息:
-
-- `run_id`
-- `policy_run_id`
-- `status`
-- `platform`
-- `platform_mode`
-- `strategy_version`
-- `validation_status`
-- `error_code`
-- `started_at`
-- `ended_at`
-- 数据源标识,例如 `demand_content_id` 或 `run_label`
-
-筛选:
-
-- 状态:success / partial_success / failed
-- 平台:默认 douyin
-- 运行模式:real / mock
-- 校验状态:pass / fail
-- 时间范围
-- 搜索:run_id、policy_run_id、error_code
-
-空状态:
-
-- 如果生产 DB 只有失败 run,页面应显示“当前生产事实主要是失败诊断数据”,不能伪造完整成功链路。
-- 如果选择 runtime 回放,则明确显示“回放导出,不是生产事实”。
-
-### 2. Run 总览页
-
-用途:进入某次 run 后先看整体健康度和链路摘要。
-
-核心区域:
-
-- 顶部运行摘要:状态、平台、策略版本、policy bundle、validation。
-- 文件 / 表完整性:哪些 artifact 存在,哪些缺失。
-- 阶段进度:用与 `show` 一致的 7 阶段条展示。
-- 关键计数:
-  - Query 数
-  - 发现内容数
-  - 入池内容数
-  - 待复看数
-  - 淘汰数
-  - 规则阻断数
-  - walk action 数
-  - strategy review 状态
-
-### 3. 数据源阶段
-
-用途:解释这次 run 从哪个真实需求或证据开始。
-
-显示:
-
-- DemandAgent 来源:`demand_content_id`、`run_label`
-- `source_context.json`
-- `ext_data.evidence_pack`
-- Pattern 证据字段:
-  - `pattern_source_system`
-  - `pattern_execution_id`
-  - `mining_config_id`
-  - `source_post_id`
-  - `matched_post_ids`
-  - `itemset_ids`
-  - `category_bindings`
-  - `decode_case_ids`
-
-保留 show 视觉:
-
-- 左侧数据源列表。
-- 顶部筛选和搜索。
-- 右侧证据详情卡片。
-
-降级或隐藏:
-
-- 小红书、Case 独立数据源、历史作者、热点、养号默认不作为 V1 入口展示。
-- 如果要展示,只能放在“旧 show 占位 / 后续能力”区域。
-
-### 4. Query 阶段
-
-用途:看 seed terms 如何生成平台搜索 Query。
-
-显示:
-
-- `search_queries.jsonl`
-- Query 文本
-- 生成方式:
-  - `item_single`
-  - `llm_variant`
-  - `tag_query`
-- `query_source_terms`
-- `query_source_fields`
-- `pattern_seed_ref`
-- LLM 输入快照和生成模型
-
-保留 show 视觉:
-
-- Query 工作台三列布局。
-- evidence / prompt / query group 的层级表达。
-- Prompt 展开弹窗。
-
-注意:
-
-- 不显示旧的硬编码“案例 / 解读 / 警示”作为默认模板。
-- Query 少于预期时必须展示失败原因,而不是默默少显示。
-
-### 5. Platform / 发现内容阶段
-
-用途:看每条 Query 拉回了什么内容,以及平台数据质量如何。
-
-显示:
-
-- `discovered_content_items.jsonl`
-- `content_media_records.jsonl`
-- `platform_content_id`
-- `platform_author_id`
-- 描述、标签、互动统计
-- `has_more`
-- `next_cursor`
-- `platform_raw_payload` 摘要
-- 媒体状态和画像可用性
-
-核心交互:
-
-- 按 Query 筛选内容。
-- 按作者筛选内容。
-- 按平台内容 ID 搜索。
-- 查看同一内容被多个 Query 命中的来源合并。
-
-### 6. 判断阶段
-
-用途:解释规则包为什么让内容入池、待复看、淘汰或规则阻断。
-
-显示:
-
-- `rule_decisions.jsonl`
-- `decision_action`
-- `decision_reason_code`
-- `search_query_effect_status`
-- `score`
-- `scorecard`
-- `triggered_blocking_rules`
-- `decision_replay_data`
-- `source_evidence`
-
-保留 show 视觉:
-
-- 规则包卡片。
-- hard gate / scorecard / threshold 分区。
-- 右侧抽屉展示细则。
-
-状态口径:
-
-- `ADD_TO_CONTENT_POOL`:入池
-- `KEEP_CONTENT_FOR_REVIEW`:待复看
-- `REJECT_CONTENT`:淘汰
-- `rule_blocked`:规则阻断
-- `failed`:普通失败
-- `pending`:待复看或需复盘
-
-### 7. 游走阶段
-
-用途:展示 P6 WalkAction 如何决定下一步动作。
-
-显示:
-
-- `walk_actions.jsonl`
-- `source_path_records.jsonl`
-- `search_clues.jsonl`
-- edge:
-  - `query_next_page`
-  - `video_to_author`
-  - `author_to_works`
-  - `author_work_to_content`
-  - `video_to_hashtag`
-  - `hashtag_to_query`
-  - `path_stop`
-  - `budget_downgrade`
-- `walk_status`
-- `walk_action_id`
-- `budget_tier`
-- `depth`
-- `reason_code`
-
-保留 show 视觉:
-
-- 游走筛选器。
-- 策略边表格。
-- 右侧边详情。
-
-降级或隐藏:
-
-- 小红书。
-- 共创。
-- 相似作者。
-- 热点。
-- 养号。
-- 相关搜索如果当前 V1 未接入真实执行,必须标为后续能力。
-
-### 8. 资产沉淀阶段
-
-用途:展示最终沉淀的内容资产、作者资产、搜索线索资产。
-
-显示:
-
-- `final_output.json`
-- `content_assets`
-- `author_assets`
-- `search_clues`
-- `reject_records`
-- `decision_records`
-- 发布任务状态摘要
-
-注意:
-
-- 内容资产和媒体记录不是同一个概念。
-- 作者资产只有满足沉淀条件才展示为正式资产。
-- 不满足条件的作者只显示在 run 记录和原因中。
-
-### 9. 策略学习阶段
-
-用途:复盘本次运行给后续策略带来的建议。
-
-显示:
-
-- `strategy_review.json`
-- `summary`
-- `metric_summary`
-- `decision_distribution`
-- `effective_search_queries`
-- `top_reject_reasons`
-- `productive_paths`
-- `suggestions`
-- `performance_feedback_status`
-
-注意:
-
-- `content_agent_performance_feedback` 当前可能没有数据。
-- 无后验表现时显示 `performance_feedback_status=missing`,不能伪造学习结论。
-- `GET /strategy-review` 只读已有 review;重算必须用显式 regenerate。
-
-### 10. Runtime / Validation 调试页
-
-用途:给工程和策略用户快速定位数据缺失和结构漂移。
-
-显示:
-
-- runtime file 列表。
-- 每个文件是否存在。
-- 每个文件记录数。
-- validation findings。
-- missing / old alias / deprecated runtime 的提示。
-
-对旧 runtime 的处理:
-
-- 旧文件名如 `queries.jsonl`、`candidate_pool.jsonl` 只在回放模式中显示。
-- 使用 migration alias 做可读提示,但不把旧名当作当前合同。
-
-## show 视觉保留范围
-
-必须保留:
-
-- 顶部 7 阶段 Pipeline。
-- 数据源阶段的筛选、搜索、左列表、右详情。
-- Query 工作台。
-- 判断规则卡和抽屉。
-- 游走工作台。
-- 资产流程卡。
-- 策略学习 trace / 建议布局。
-
-必须替换:
-
-- 静态 demo 数据。
-- 模拟资产文字。
-- “页面模拟,不入库”类型文案。
-- 旧占位能力的默认展示。
-
-必须降级:
-
-- 小红书。
-- 热点。
-- 养号。
-- 共创。
-- 相似作者。
-- 历史作者。
-- Case 独立入口。
-
-这些只能作为“旧 show 占位 / 后续能力”,不能出现在 V1 默认真实链路里。

+ 0 - 300
web/docs/02_数据与接口规划.md

@@ -1,300 +0,0 @@
-# Web 数据与接口规划
-
-## 数据源总原则
-
-新 Web 使用两层数据源:
-
-1. 生产事实层:FastAPI 后端读取云 MySQL `content_agent_*` 表或后端聚合后的事实数据。
-2. 回放辅助层:本地 `runtime/v1/{run_id}/` JSON / JSONL 兼容导出。
-
-UI 必须明确标识数据来源:
-
-- `生产事实`:来自 DB / 后端聚合。
-- `回放导出`:来自 runtime JSON / JSONL。
-- `缺失`:当前 run 没有该文件或该表无数据。
-- `历史格式`:旧 runtime 文件名或旧结构。
-
-前端不得直接连接 MySQL,也不得直接读取任意文件路径。
-
-## 现有 FastAPI 接口
-
-当前 FastAPI 位于 `content_agent/api.py`。
-
-| Method | Path | 用途 | 当前响应 |
-|---|---|---|---|
-| `POST` | `/runs` | 启动一次运行 | `RunStartResponse` |
-| `GET` | `/runs/{run_id}` | 查询单 run 摘要 | `RunSummaryResponse` |
-| `GET` | `/runs/{run_id}/discovered-content-items` | 查询发现内容 JSONL | `RecordsResponse` |
-| `GET` | `/runs/{run_id}/rule-decisions` | 查询规则判断 JSONL | `RecordsResponse` |
-| `GET` | `/runs/{run_id}/source-path-records` | 查询来源路径 JSONL | `RecordsResponse` |
-| `GET` | `/runs/{run_id}/final-output` | 查询最终输出 JSON | `JsonFileResponse` |
-| `GET` | `/runs/{run_id}/strategy-review` | 查询已有策略复盘 | `JsonFileResponse` |
-| `POST` | `/runs/{run_id}/strategy-review/regenerate` | 显式重新生成策略复盘 | `JsonFileResponse` |
-| `GET` | `/runs/{run_id}/validation` | 查询运行完整性校验 | `ValidationResponse` |
-
-现有缺口:
-
-- 没有 `GET /runs` run 列表。
-- 没有 dashboard 聚合接口。
-- 没有统一 runtime file 白名单读取接口。
-- 没有 Query、Timeline、Content Items 的 Web 聚合视图。
-- 没有 CORS / API base URL 的前端联调配置说明。
-
-## 现有 runtime 文件
-
-当前正式 runtime 文件:
-
-- `source_context.json`
-- `pattern_seed_pack.json`
-- `search_queries.jsonl`
-- `discovered_content_items.jsonl`
-- `content_media_records.jsonl`
-- `pattern_recall_evidence.jsonl`
-- `rule_decisions.jsonl`
-- `walk_actions.jsonl`
-- `run_events.jsonl`
-- `source_path_records.jsonl`
-- `search_clues.jsonl`
-- `final_output.json`
-- `strategy_review.json`
-
-Web 不应该直接绑定本地文件路径。后端如果暴露 runtime 文件,必须按 `RUNTIME_FILENAMES` 白名单读取。
-
-## 当前 DB 可展示实体
-
-当前生产 DB 是 21 张 `content_agent_*` 表。Web 第一版重点展示这些实体:
-
-| 实体 | 表 / 文件 | 用途 |
-|---|---|---|
-| Run | `content_agent_runs` | run 列表、状态、错误、时间 |
-| Source Context | `content_agent_source_contexts` / `source_context.json` | 需求来源和 evidence_pack |
-| Pattern Seed | `content_agent_pattern_seed_packs` / `pattern_seed_pack.json` | seed terms、itemset、category bindings |
-| Query | `content_agent_queries` / `search_queries.jsonl` | Query 文本、生成方式、LLM 变体 |
-| Discovered Content | `content_agent_discovered_content_items` / `discovered_content_items.jsonl` | 发现内容 |
-| Media Record | `content_agent_content_media_records` / `content_media_records.jsonl` | 媒体链接、画像、互动数据 |
-| Pattern Recall | `content_agent_pattern_recall_evidence` / `pattern_recall_evidence.jsonl` | P4 回扣证据 |
-| Rule Decision | `content_agent_rule_decisions` / `rule_decisions.jsonl` | P5 判断结果 |
-| Walk Action | `content_agent_walk_actions` / `walk_actions.jsonl` | P6 游走动作 |
-| Run Event | `content_agent_run_events` / `run_events.jsonl` | 运行事件和失败定位 |
-| Source Path | `content_agent_source_path_records` / `source_path_records.jsonl` | 来源链路 |
-| Search Clue | `content_agent_search_clues` / `search_clues.jsonl` | Query 效果聚合 |
-| Final Output | `content_agent_final_outputs` / `final_output.json` | 最终视图快照 |
-| Strategy Review | `content_agent_strategy_reviews` / `strategy_review.json` | 策略复盘 |
-| Author Asset | `content_agent_author_assets` | 作者资产 |
-| Performance Feedback | `content_agent_performance_feedback` | 后验表现反馈 |
-
-## 新增 Web 友好接口规划
-
-**实施现状(2026-06-10):以下 1-7 号接口已全部实现并提交(后端 commit 6ee0079;M6 为 timeline 增补 summary 七字段,commit ed13f10)。第 8 节配置只读接口由 Web V2 实做。**
-
-### 1. `GET /runs`
-
-用途:Run 列表页。
-
-查询参数:
-
-- `status`
-- `platform`
-- `platform_mode`
-- `strategy_version`
-- `validation_status`
-- `error_code`
-- `started_from`
-- `started_to`
-- `page`
-- `page_size`
-
-返回建议:
-
-```json
-{
-  "items": [
-    {
-      "run_id": "v1_run_xxx",
-      "policy_run_id": "policy_run_xxx",
-      "status": "success",
-      "platform": "douyin",
-      "platform_mode": "real",
-      "strategy_version": "V1",
-      "validation_status": "pass",
-      "error_code": null,
-      "started_at": "...",
-      "ended_at": "..."
-    }
-  ],
-  "page": 1,
-  "page_size": 20,
-  "total": 0
-}
-```
-
-### 2. `GET /runs/{run_id}/dashboard`
-
-用途:Run 总览页一次加载所需摘要。
-
-返回建议:
-
-- `summary`
-- `file_status`
-- `validation`
-- `final_output_summary`
-- `strategy_review_status`
-- `counts`
-- `data_source_label`
-- `data_origin`
-- `links`
-
-其中 `data_origin` 只允许:
-
-- `production_db`
-- `runtime_export`
-- `mixed_with_runtime_export`
-
-### 3. `GET /runs/{run_id}/runtime-files`
-
-用途:Runtime / Validation 调试页。
-
-返回:
-
-- 文件名
-- 是否存在
-- 记录数
-- 文件类型:json / jsonl
-- 当前合同状态
-- 是否历史 alias
-
-### 4. `GET /runs/{run_id}/runtime-files/{filename}`
-
-用途:调试查看原始 runtime artifact。
-
-要求:
-
-- `filename` 必须在 `RUNTIME_FILENAMES` 白名单内。
-- 支持 `limit` / `offset`。
-- 不允许 `../`、绝对路径或任意文件名。
-
-### 5. `GET /runs/{run_id}/queries`
-
-用途:Query 阶段。
-
-聚合:
-
-- `search_queries`
-- `search_clues`
-- `rule_decisions` 聚合计数
-- Query 失败事件
-
-返回每条 Query:
-
-- `search_query_id`
-- `search_query`
-- `search_query_generation_method`
-- `query_source_terms`
-- `result_count`
-- `pooled_content_count`
-- `review_content_count`
-- `pending_content_count`
-- `rejected_content_count`
-- `search_query_effect_status`
-- `walk_next_step`
-- `failure_reason`
-
-### 6. `GET /runs/{run_id}/timeline`
-
-用途:阶段时间线和运行诊断。
-
-聚合:
-
-- `run_events`
-- `walk_actions`
-- `source_path_records`
-
-返回:
-
-- stage
-- event_type
-- status
-- timestamp
-- source artifact
-- target artifact
-- error_code
-- walk_action_id
-
-### 7. `GET /runs/{run_id}/content-items`
-
-用途:Platform、判断和内容详情页。
-
-查询参数:
-
-- `search_query_id`
-- `decision_action`
-- `effect_status`
-- `author_id`
-- `platform_content_id`
-- `page`
-- `page_size`
-
-聚合:
-
-- discovered content
-- content media
-- pattern recall evidence
-- rule decision
-- source path
-
-返回每条内容:
-
-- 内容基础信息
-- 作者信息
-- Query 来源
-- 互动统计
-- Pattern 回扣结果
-- 规则判断结果
-- source path 引用
-
-### 8. 配置只读接口(Web V2 实做)
-
-用途:配置即真相只读视图(Web-V2-B);避免前端解析本地 JSON 源文件。三个端点均为全局只读、无参数、无写能力:
-
-- `GET /config/rule-packs` → `{source_file, data}`,data 为 `product_documents/规则包/douyin_rule_packs.v1.json` 全文(dispatch 5 条 / Content 包 hard_gates / scorecard / thresholds / effect_status_mapping / decision_reason_codes)
-- `GET /config/walk-strategy` → 同构,`product_documents/抖音游走策略/douyin_walk_strategy.v1.json` 全文(walk_rule_pack_binding 8 条 / edge catalog 10 条 / budget / stop policy)
-- `GET /config/query-prompts` → 同构,`product_documents/配置/query_prompts.v1.json` 全文(douyin/V1 profile)
-
-约束:只读展示;编辑仍走 Excel→JSON 工具链(M1);响应不含任何密钥(配置文件本身无密钥)。
-
-## 前端 API 配置
-
-Next.js 前端第一版使用:
-
-- `VITE_CONTENTFIND_API_BASE_URL`
-
-如果 Next.js 使用 `NEXT_PUBLIC_*` 命名,技术实现阶段可选择:
-
-- 保留 `VITE_CONTENTFIND_API_BASE_URL` 作为兼容名。
-- 新增 `NEXT_PUBLIC_CONTENTFIND_API_BASE_URL`。
-- 在 `web/.env.example` 同时写两个 key,但 runtime 只使用一个。
-
-本规划默认:实现时使用 `NEXT_PUBLIC_CONTENTFIND_API_BASE_URL`,并在文档中说明旧 `VITE_CONTENTFIND_API_BASE_URL` 是历史/兼容命名。
-
-## 错误与缺失数据展示
-
-Web 必须优雅处理:
-
-- run 不存在。
-- runtime 文件缺失。
-- DB 中某表暂无数据。
-- validation fail。
-- strategy review 未生成。
-- performance feedback missing。
-- DB run 全是 failed。
-- 历史 runtime 旧命名。
-
-页面不得因为缺一个 artifact 整体白屏。
-
-## 不做
-
-- 前端直连 MySQL。
-- 前端读取本地任意文件路径。
-- 在前端拼接 DB SQL。
-- 把 runtime export 当作生产事实。
-- 把 show 静态 demo 当作真实 run。

+ 0 - 437
web/docs/03_技术开发规划.md

@@ -1,437 +0,0 @@
-# Web 技术开发规划
-
-## 工程形态
-
-新 Web 使用独立 Next.js 应用,代码放在项目根目录 `web/`。
-
-后续推荐目录:
-
-```text
-web/
-  app/
-    page.tsx
-    runs/
-      page.tsx
-      [runId]/
-        page.tsx
-  components/
-    layout/
-    pipeline/
-    cards/
-    tables/
-    drawer/
-    badges/
-  features/
-    runs/
-    pipeline/
-    source/
-    query/
-    platform/
-    rules/
-    walk/
-    assets/
-    review/
-    runtime/
-  lib/
-    api/
-    adapters/
-    formatters/
-    status/
-  docs/
-```
-
-本轮只写规划文档,不初始化 Next.js,不生成 `package.json`,不新增前端依赖。
-
-## 技术原则
-
-- 视觉复刻 `show`,数据层重写。
-- 组件按业务阶段拆分,不再写 4000 行单文件。
-- API client 集中管理,不在组件里散落 `fetch`。
-- Adapter 负责把后端数据变成 UI view model。
-- UI 不直接依赖 runtime 原始字段的所有细节。
-- 缺数据时显示明确空状态,不白屏。
-- 生产事实和回放导出使用不同 badge。
-
-## 前端模块规划
-
-### `features/runs`
-
-职责:
-
-- Run 列表。
-- Run 筛选。
-- Run 状态 badge。
-- 最近运行入口。
-
-依赖接口:
-
-- `GET /runs`
-- `GET /runs/{run_id}/dashboard`
-
-### `features/pipeline`
-
-职责:
-
-- 顶部 7 阶段 Pipeline。
-- 阶段状态高亮。
-- 阶段切换。
-
-视觉参考:
-
-- `show/src/App.tsx` 的 `pipelineStages`
-- `PipelineHeader`
-- `show/src/styles.css` 的 `.pipeline-header`、`.pipeline-grid`、`.pipeline-step`
-
-### `features/source`
-
-职责:
-
-- 数据源阶段。
-- evidence_pack 展示。
-- Pattern seed 和 DemandAgent 来源说明。
-
-依赖接口:
-
-- dashboard 中的 data source 摘要。
-- runtime file `source_context.json`。
-- runtime file `pattern_seed_pack.json`。
-
-### `features/query`
-
-职责:
-
-- Query 工作台。
-- Query 列表、生成方式、来源字段。
-- Query 效果。
-- LLM 变体输入快照。
-
-依赖接口:
-
-- `GET /runs/{run_id}/queries`
-
-### `features/platform`
-
-职责:
-
-- 发现内容列表。
-- 内容详情。
-- 媒体记录。
-- 平台 raw payload 摘要。
-
-依赖接口:
-
-- `GET /runs/{run_id}/content-items`
-- 必要时 runtime file `content_media_records.jsonl`
-
-### `features/rules`
-
-职责:
-
-- 规则判断卡片。
-- hard gate / scorecard / threshold 展示。
-- RuleDecision 抽屉。
-
-依赖接口:
-
-- `GET /runs/{run_id}/content-items`
-- `GET /runs/{run_id}/runtime-files/rule_decisions.jsonl`
-
-### `features/walk`
-
-职责:
-
-- Walk action 工作台。
-- edge 筛选。
-- source path 追溯。
-- Query 下一页 / 作者 / tag 路径展示。
-
-依赖接口:
-
-- `GET /runs/{run_id}/timeline`
-- `GET /runs/{run_id}/runtime-files/walk_actions.jsonl`
-- `GET /runs/{run_id}/runtime-files/source_path_records.jsonl`
-
-### `features/assets`
-
-职责:
-
-- 内容资产。
-- 作者资产。
-- 搜索线索资产。
-- 淘汰记录。
-
-依赖接口:
-
-- `GET /runs/{run_id}/final-output`
-- 后续资产聚合接口。
-
-### `features/review`
-
-职责:
-
-- 策略复盘。
-- Query / rule / walk 建议。
-- performance feedback 缺失状态。
-
-依赖接口:
-
-- `GET /runs/{run_id}/strategy-review`
-- `POST /runs/{run_id}/strategy-review/regenerate`
-
-### `features/runtime`
-
-职责:
-
-- runtime file 状态。
-- validation findings。
-- 原始 artifact 调试。
-
-依赖接口:
-
-- `GET /runs/{run_id}/runtime-files`
-- `GET /runs/{run_id}/runtime-files/{filename}`
-- `GET /runs/{run_id}/validation`
-
-## 后端配套规划
-
-### API Service
-
-新增后端 service 层,不建议在 `api.py` 里直接拼复杂数据。
-
-建议新增:
-
-- `content_agent/dashboard_service.py`
-- 或 `content_agent/business_modules/dashboard/`
-
-职责:
-
-- run list 聚合。
-- dashboard summary 聚合。
-- query 聚合。
-- content item 聚合。
-- timeline 聚合。
-- runtime file 安全读取。
-
-### CORS
-
-开发期需要允许 Next.js dev server 调 FastAPI。
-
-建议:
-
-- 新增 env:`CONTENT_AGENT_WEB_CORS_ORIGINS`
-- 默认开发允许 `http://127.0.0.1:3000` 和 `http://localhost:3000`
-- 生产环境显式配置
-
-### Runtime File 白名单
-
-后端必须使用 `RUNTIME_FILENAMES` 限制可读文件。
-
-禁止:
-
-- 任意 path
-- `../`
-- 绝对路径
-- 未登记 runtime 文件名
-
-### DB 与 runtime 的读取策略
-
-默认读取生产事实层。
-
-当生产事实缺少完整成功链路、用户选择回放模式时,可读取 runtime export,但 response 必须带:
-
-```json
-{
-  "data_origin": "runtime_export"
-}
-```
-
-如果同时使用 DB 和 runtime:
-
-```json
-{
-  "data_origin": "mixed_with_runtime_export"
-}
-```
-
-## 开发顺序
-
-### Web-0:文档与合同确认
-
-本轮完成:
-
-- `web/README.md`
-- 产品功能规划
-- 数据与接口规划
-- 技术开发规划
-- show 视觉迁移清单
-
-### Web-1:后端 Web API
-
-先补:
-
-- `GET /runs`
-- `GET /runs/{run_id}/dashboard`
-- `GET /runs/{run_id}/runtime-files`
-- `GET /runs/{run_id}/runtime-files/{filename}`
-
-验收:
-
-- FastAPI tests 覆盖列表、dashboard、白名单读取、非法 filename。
-- 不影响现有 `/runs/{run_id}` 等接口。
-
-### Web-2:Next.js 初始化
-
-在 `web/` 初始化 Next.js。
-
-最低要求:
-
-- TypeScript
-- App Router
-- CSS Modules 或全局 CSS
-- API base URL 配置
-- 基础 layout
-
-不在这一步接复杂业务。
-
-### Web-3:Run 列表与总览
-
-实现:
-
-- Run 列表。
-- Run 总览。
-- Pipeline 阶段条。
-- validation 状态。
-- artifact 状态。
-
-### Web-4:阶段详情
-
-实现:
-
-- 数据源。
-- Query。
-- Platform / 内容。
-- 判断。
-- 游走。
-- 资产。
-- 策略学习。
-
-### Web-5:视觉对齐 show
-
-对齐:
-
-- 布局密度。
-- 按钮形态。
-- 筛选器。
-- 抽屉。
-- 卡片。
-- 状态 badge。
-- trace strip。
-
-### Web-6:回放模式
-
-实现 runtime export 的只读回放。
-
-要求:
-
-- 明确标注“回放导出”。
-- 支持历史缺文件。
-- 支持旧 runtime alias 的提示。
-
-## 测试规划
-
-### 后端测试
-
-- `GET /runs` 正常返回列表。
-- `GET /runs` 支持筛选。
-- `GET /runs/{run_id}/dashboard` 返回 summary / counts / data_origin。
-- `GET /runs/{run_id}/runtime-files` 返回 13 个 runtime 文件状态。
-- 非白名单 filename 返回结构化错误。
-- 不存在 run 返回 `RUN_NOT_FOUND`。
-- strategy review GET 不产生写副作用。
-
-### 前端测试
-
-后续 Next.js 初始化后再加:
-
-- Run 列表渲染。
-- Run 总览渲染。
-- 阶段切换。
-- 空状态。
-- runtime export badge。
-- API 错误状态。
-
-### 视觉验收
-
-用 Playwright 对比:
-
-- 顶部 Pipeline 是否存在 7 阶段。
-- 数据源页是否为筛选 + 左列表 + 右详情。
-- 判断页是否有规则卡和抽屉。
-- 游走页是否有筛选器和边详情。
-- 移动端不作为第一优先级;当前 show 本身是宽屏工作台。
-
-## Web V2 迭代规划(消费 CFA V2 可观测性与配置即真相)
-
-> 本节为 Web 的 V2 轮规划,对应后端 `tech_documents/工程落地/06_V2阶段开发计划.md` 的 V2-M6(可观测性)/ M1·M3·M4(配置即真相 + 按边 `rule_pack_id`)/ M0(真实 case 回放)。**后端 V2 只产出数据与 API,前端 V2 在此独立迭代**(由 web 项目单独排期)。CFA V2 锁定范围:单平台(抖音)单包(Content)跑通「解耦机制 + 配置即真相 + 可观测」;配置可视化「编辑」UI 不在 V2(含 web V2)。
-
-### 现状对齐(与本文档第一版的差异)
-
-本文档第一版写「本轮只写规划,不初始化 Next.js」,但实际 web 已落地骨架与基础消费,Web V2 是在此之上「消费 CFA V2 新增事实」,不是从零搭:
-
-- 前端已初始化并提交(Next.js 15 + App Router,commit c6c5e39,2026-06-10):`app/runs/page.tsx`、`app/runs/[runId]/page.tsx`,feature 页 `features/runs/RunListPage.tsx`、`RunDashboardPage.tsx`,组件 `components/pipeline/PipelineHeader.tsx`、`components/cards/*`、`components/badges/StatusBadge.tsx`,`lib/api/client.ts`、`lib/adapters/pipeline.ts`。
-- 后端 Web-1 API 已提交(commit 6ee0079,2026-06-10):`content_agent/dashboard_service.py`(`list_runs` / `dashboard` / `queries` / `timeline` / `content_items` / `runtime_files` / `runtime_file`),`api.py` 已注册 `GET /runs`、`/runs/{id}/dashboard|queries|timeline|content-items|runtime-files|runtime-files/{filename}`,`schemas.py` 有对应 response model,CORS 已配。
-- 即 Web-0…Web-5 的「骨架 + 基础消费」事实上已落地;Web V2 聚焦三块新消费。
-- **依赖关系**:CFA V2 已全部交付(M3=42d0802 / M4=410cd72 / M5=7e86c9a / M6=ed13f10,2026-06-10),新增字段均为增量,旧消费不破坏;adapter 对缺字段仍降级为空状态,不白屏。
-
-### Web-V2-A:全流程时间线与耗时/失败事实可视化(消费 CFA V2-M6,已落地)
-
-职责:
-
-- 把现 `timeline` 的扁平事件流升级为「阶段耗时 + 事件流(日志线)」视图(横条即可,不引甘特图库)。
-- 展示 summary 七字段:`total_duration_ms` / `stage_duration_ms` / `query_failure_count` / `platform_rate_limited_count` / `decode_status_counts` / `error_counts` / `walk_status_counts`。
-- decode 逐条中间事件(五类:`decode_submitted` / `decode_polling` / `decode_succeeded` / `decode_failed` / `decode_timeout`)。
-- `stage_failed` 事件高亮并展示 error_code / 脱敏 message——前端只呈现事实。
-
-依赖接口(CFA V2-M6 已落地,commit ed13f10):
-
-- `GET /runs/{run_id}/timeline`:summary 七字段在响应顶层 `summary`;单条事件的耗时细节在 `items[i].record.raw_payload`(`stage` / `started_at` / `ended_at` / `duration_ms` / `attempt`)。
-- **M6 拍板红线:后端不产生 `stall_flag` / `stall_reason` / `stalls[]` 等"卡住"判断字段;"卡住"的业务阈值留待真实运行数据积累后由产品定义,前端不得自行发明判断。**
-
-### Web-V2-B:配置即真相只读视图(消费 CFA V2-M1 / M3 / M4)
-
-职责:
-
-- 展示当前生效的规则包(Content 包 hard gates / scorecard / thresholds)与游走 `walk_rule_pack_binding`(边→包)。
-- 在 walk 视图按边展示 `walk_actions.rule_pack_id`(M4 已落地,按边正确非 NULL),并区分「边归属包」与「实际执行包」(`raw_payload.rule_pack_execution.executed` / `executed_rule_pack_id`);future 包(`dispatch_enabled=false`)标注为「已归属未运行」。
-
-依赖接口:
-
-- `GET /runs/{run_id}/content-items` + `runtime-files/rule_decisions.jsonl`(已存在)。
-- `runtime-files/walk_actions.jsonl`(已存在;`rule_pack_id` 与 `raw_payload.rule_pack_binding` / `rule_pack_execution` 已由 CFA V2-M4 落地)。
-- 后端只读配置接口(Web V2 实做):`GET /config/rule-packs`、`GET /config/walk-strategy`、`GET /config/query-prompts`,返回 product_documents 配置 JSON 原文 + source_file 标注;接口合同见 `02_数据与接口规划.md` 第 8 节。
-
-### Web-V2-C:回放数据来源标注(缩范围;diff 视图推迟)
-
-职责(本轮范围):
-
-- 复用现有 `data_origin` badge 区分「生产事实(production_db) / 回放导出(runtime_export) / 混合(mixed_with_runtime_export)」——已实现,保持。
-
-推迟项与原因:
-
-- 「同一 case 不同配置的 snapshot diff 视图」推迟:M0 回放是 pytest 测试设施(`tests/replay_harness.py` 只写临时目录),**没有生产侧回放导出 API 或持久化产物**;做 diff 视图需先给后端补"回放产物导出 / 回放触发"配套,列入后续迭代再拍板。
-
-### Web V2 不做
-
-- run 异步化与「运行中进度轮询」——POST /runs 当前同步阻塞,运行中无进度可查;本轮已拍板不做,留待后续迭代(需后端异步任务 + 轮询端点配套)。
-- 配置可视化「编辑」(写)能力——CFA V2 傻瓜配置上限是「业务改 Excel + CI 兜底」,可视化编辑 UI 留 Web V3;Web V2 只做配置「只读展示」。
-- 移动端适配。
-- 前端直连 MySQL。
-
-## 不做
-
-- 本轮不搭 Next.js 代码。
-- 本轮不改 FastAPI。
-- 本轮不改 DB。
-- 本轮不改 `show/`。
-- 后续也不从前端直连 MySQL。
-- 不把 `show` 静态数据迁移成事实源。

+ 0 - 288
web/docs/04_show视觉迁移清单.md

@@ -1,288 +0,0 @@
-# show 视觉迁移清单
-
-本文件用于指导新 `web/` 复刻旧 `show/` 的视觉和交互。迁移目标是“用户看起来像 show”,不是复用旧静态数据。
-
-## 总体结论
-
-`show/` 当前是 Vite React 单页应用:
-
-- 入口:`show/src/main.tsx`
-- 主体:`show/src/App.tsx`
-- 样式:`show/src/styles.css`
-- 静态构建:`show/dist/`
-
-`show/src/App.tsx` 是单文件大组件,包含类型、静态数据、页面组件和全部交互。新 Web 不应继续这种结构,应拆为 Next.js 页面、feature 组件和 adapter。
-
-## 必须保留的视觉 / 交互
-
-### 1. 顶部 PipelineHeader
-
-来源:
-
-- `pipelineStages`
-- `PipelineHeader`
-- `.pipeline-header`
-- `.pipeline-grid`
-- `.pipeline-step`
-
-保留:
-
-- 7 阶段横向条。
-- 当前阶段蓝色高亮。
-- 数据源和 Platform 可作为下拉选择区域。
-- 其它阶段作为点击按钮。
-
-替换:
-
-- 数据源选项以当前 V1 真实链路为准。
-- 平台默认只显示抖音;其他平台放后续能力或隐藏。
-
-### 2. DataSourceStageBrowser
-
-来源:
-
-- `DataSourceStageBrowser`
-- `SourceFilterBar`
-- `SourceRecordList`
-- `SourceRecordDetail`
-- `.source-filter-bar`
-- `.source-browser`
-- `.source-list-panel`
-- `.source-detail-panel`
-
-保留:
-
-- 顶部筛选条。
-- 搜索框。
-- 左侧记录列表。
-- 左侧列表可收起。
-- 右侧详情卡片。
-
-替换:
-
-- 静态 Pattern / Case / 历史作者 / 热点 / 养号记录。
-- 改为真实 run 的 source context、evidence pack、pattern seed。
-
-降级:
-
-- Case 独立入口。
-- 历史优质搜索记录。
-- 历史沉淀账号。
-- 热点。
-- 养号。
-
-这些默认不作为 V1 Web 的主入口。
-
-### 3. QueryWorkshop
-
-来源:
-
-- `QueryWorkshopView`
-- `PromptModal`
-- Query evidence / prompt / query group 三列布局
-
-保留:
-
-- Query 工作台。
-- evidence、prompt、query 分区。
-- Prompt 展开查看。
-- Query group 说明。
-
-替换:
-
-- 静态 prompt 文案。
-- 静态 query 示例。
-
-接入:
-
-- `search_queries.jsonl`
-- Query 生成方式。
-- LLM 输入快照。
-- `search_clues.jsonl` 聚合效果。
-
-### 4. JudgeRulePackBoard
-
-来源:
-
-- `JudgeRulePackBoard`
-- `RulePackCard`
-- `RulePackDrawer`
-
-保留:
-
-- 规则包卡片。
-- 分组展示。
-- 抽屉详情。
-- hard gate / scorecard / threshold / output 分区。
-
-替换:
-
-- 静态规则解释。
-- 静态 scorecard。
-
-接入:
-
-- `rule_decisions.jsonl`
-- `decision_replay_data`
-- `scorecard`
-- `triggered_blocking_rules`
-- `decision_reason_code`
-
-### 5. WalkStrategyWorkbench
-
-来源:
-
-- `WalkStrategyWorkbench`
-- `walkFilterGroups`
-- `walkStrategyRows`
-- `WalkStrategyEdgeDetail`
-
-保留:
-
-- 游走筛选器。
-- 策略边表格。
-- 当前选中边详情。
-- 继续 / 停止 / 降预算等动作表达。
-
-替换:
-
-- 静态 walk events。
-- 小红书、共创、相似作者等旧占位。
-
-接入:
-
-- `walk_actions.jsonl`
-- `source_path_records.jsonl`
-- `search_clues.jsonl`
-- `run_events.jsonl`
-
-### 6. AssetFlowBoard
-
-来源:
-
-- `AssetFlowBoard`
-- `assetFlows`
-
-保留:
-
-- 资产流程卡。
-- 内容资产、作者资产、来源关系、搜索线索的视觉分区。
-
-替换:
-
-- “页面模拟,不入库”。
-- 静态资产示例。
-
-接入:
-
-- `final_output.json`
-- `content_assets`
-- `author_assets`
-- `search_clues`
-- `reject_records`
-
-### 7. StrategyLearningBoard
-
-来源:
-
-- `StrategyLearningBoard`
-- `learningMethodSteps`
-- `learningTraceGroups`
-- `learningRecommendations`
-- `learningExperiments`
-
-保留:
-
-- 策略学习方法区。
-- trace 数据分组。
-- 建议列表。
-- 实验 / 下轮动作表达。
-
-替换:
-
-- 静态学习建议。
-- 静态实验示例。
-
-接入:
-
-- `strategy_review.json`
-- `performance_feedback`
-- `suggestions`
-- `productive_paths`
-- `top_reject_reasons`
-
-## 必须删除或降级的 show 旧口径
-
-### 不进入 V1 默认展示
-
-- 小红书。
-- 快手。
-- B 站。
-- 视频号。
-- 票圈。
-- 热点。
-- 养号。
-- 历史作者。
-- 共创作者。
-- 相似作者。
-- 推荐流。
-- Case 独立数据源。
-
-### 只能作为“后续能力”展示
-
-如果产品需要保留这些按钮的视觉位置,应以禁用态或后续能力卡展示:
-
-- 文案:`后续能力`
-- 状态:disabled
-- 不触发 API
-- 不参与真实 run 统计
-
-### 不迁移为事实的静态内容
-
-- `demoScenarios`
-- 静态 Pattern ID 和 Case ID。
-- 静态作者作品接口返回。
-- 静态 query workshop 文案。
-- 静态规则包解释。
-- 静态策略学习建议。
-- “页面模拟,不入库”相关表格。
-
-## 新 Web 的视觉验收标准
-
-用户第一眼应觉得它和 show 是同一个产品:
-
-- 顶部阶段条相似。
-- 页面密度相似。
-- 卡片边框、圆角、表格和状态 badge 相似。
-- 数据源页的左右结构相似。
-- 判断页的规则卡和抽屉相似。
-- 游走页的筛选与边详情相似。
-
-但用户继续点击后,应看到真实 run 数据,而不是静态样例。
-
-## 迁移实施建议
-
-1. 先复制视觉 token:
-   - 字体。
-   - 背景色。
-   - 卡片边框。
-   - badge。
-   - 表格密度。
-
-2. 再拆组件:
-   - Pipeline。
-   - StageLayout。
-   - RecordList。
-   - DetailPanel。
-   - RuleDrawer。
-   - WalkWorkbench。
-
-3. 最后接数据:
-   - Run list。
-   - Dashboard。
-   - Queries。
-   - Content items。
-   - Timeline。
-   - Runtime files。
-
-不要反过来先搬静态数据。

+ 0 - 413
web/docs/05_Web实施简报.md

@@ -1,413 +0,0 @@
-# Web 实施简报
-
-## 状态
-
-本简报用于 Web 可视化正式开发前的执行拆分。它只约束 `web/` 新项目和必要的后端只读 Web API,不要求修改 `show/`,也不把 `show/` 的静态数据迁移为事实源。
-
-当前依据:
-
-- `web/docs/01_产品功能规划.md` 已定义页面和用户目标。
-- `web/docs/02_数据与接口规划.md` 已盘点现有 FastAPI、DB 表和 runtime 文件。
-- `web/docs/03_技术开发规划.md` 已规划 Next.js 目录、后端配套和开发顺序。
-- `web/docs/04_show视觉迁移清单.md` 已明确哪些 show 视觉保留、哪些旧入口降级。
-
-## 总目标
-
-开发一个独立 Next.js Web 应用,让用户用接近 `show/` 的视觉和交互查看真实 CFA run 数据:
-
-- DB 数据是生产事实。
-- runtime JSON / JSONL 是回放导出。
-- 页面不伪造完整成功链路。
-- 前端不直连 MySQL,不任意读本地文件路径。
-- `show/` 只作为视觉参考,不再作为未来事实源。
-
-## 不修改范围
-
-- 不在 `show/` 中继续开发新功能。
-- 不把旧 show 静态样例作为真实数据。
-- 不在前端直接读取 MySQL。
-- 不在前端传任意文件路径给后端。
-- 不新增 DB 表作为 Web 前置条件。
-- 不改变已有 run 成功响应字段。
-- 不改变 P0-P6 业务链路语义。
-
-## Web-A:后端只读 Web API
-
-**状态:已完成(commit 6ee0079,2026-06-10)。以下为实施前简报原文。**
-
-### 目标
-
-先补齐 Web 需要的只读聚合接口。前端第一版不要直接拼 runtime 文件,也不要在组件里理解所有 DB 表细节。
-
-### 修改范围
-
-建议新增:
-
-- `content_agent/dashboard_service.py`
-- 或 `content_agent/business_modules/dashboard/`
-
-建议修改:
-
-- `content_agent/api.py`
-- `content_agent/integrations/runtime_files.py`
-- `content_agent/integrations/database_runtime.py`
-
-### 涉及接口
-
-新增规划接口:
-
-- `GET /runs`
-- `GET /runs/{run_id}/dashboard`
-- `GET /runs/{run_id}/queries`
-- `GET /runs/{run_id}/timeline`
-- `GET /runs/{run_id}/content-items`
-- `GET /runs/{run_id}/runtime-files`
-- `GET /runs/{run_id}/runtime-files/{filename}`
-
-保留现有接口:
-
-- `POST /runs`
-- `GET /runs/{run_id}`
-- `GET /runs/{run_id}/discovered-content-items`
-- `GET /runs/{run_id}/rule-decisions`
-- `GET /runs/{run_id}/source-path-records`
-- `GET /runs/{run_id}/final-output`
-- `GET /runs/{run_id}/strategy-review`
-- `POST /runs/{run_id}/strategy-review/regenerate`
-- `GET /runs/{run_id}/validation`
-
-### 数据合同
-
-`GET /runs` 返回 run 列表,最少包含:
-
-- `run_id`
-- `policy_run_id`
-- `status`
-- `platform`
-- `platform_mode`
-- `strategy_version`
-- `validation_status`
-- `error_code`
-- `started_at`
-- `ended_at`
-
-`GET /runs/{run_id}/dashboard` 返回总览摘要,最少包含:
-
-- `summary`
-- `counts`
-- `file_status`
-- `validation`
-- `final_output_summary`
-- `strategy_review_status`
-- `data_origin`
-
-`data_origin` 只允许:
-
-- `production_db`
-- `runtime_export`
-- `mixed_with_runtime_export`
-
-`GET /runs/{run_id}/runtime-files/{filename}` 必须只允许读取 `RUNTIME_FILENAMES` 白名单内文件。
-
-### 验证命令
-
-```bash
-uv run pytest tests/test_api.py -q
-uv run python scripts/validate_schema_registry.py
-uv run --with pymysql python scripts/validate_content_agent_db.py --env-file .env
-```
-
-### 失败归因
-
-- API 返回缺字段:归因到 dashboard service 聚合合同。
-- runtime 文件读取越界:归因到白名单校验。
-- DB 查不到完整成功链路:归因到当前数据状态,页面只能展示失败诊断或空状态,不能伪造数据。
-- schema validator 失败:归因到 registry / schema,不归因到 Web 前端。
-
-## Web-B:Next.js 项目初始化
-
-**状态:已完成(commit c6c5e39,2026-06-10)。以下为实施前简报原文。**
-
-### 目标
-
-在 `web/` 下初始化独立 Next.js 应用,建立项目骨架、基础布局和 API client。
-
-### 修改范围
-
-新增:
-
-- `web/package.json`
-- `web/next.config.*`
-- `web/tsconfig.json`
-- `web/app/`
-- `web/components/`
-- `web/features/`
-- `web/lib/`
-
-### 目录合同
-
-```text
-web/
-  app/
-  components/
-    layout/
-    pipeline/
-    cards/
-    tables/
-    drawer/
-    badges/
-  features/
-    runs/
-    pipeline/
-    source/
-    query/
-    platform/
-    rules/
-    walk/
-    assets/
-    review/
-    runtime/
-  lib/
-    api/
-    adapters/
-    formatters/
-    status/
-```
-
-### 配置
-
-前端 API base URL 建议使用:
-
-- `NEXT_PUBLIC_CONTENTFIND_API_BASE_URL`
-
-如果为了兼容旧规划保留 `VITE_CONTENTFIND_API_BASE_URL`,只能作为文档兼容名,不作为 Next.js 主配置名。
-
-### 验证命令
-
-```bash
-cd web
-npm run lint
-npm run build
-```
-
-### 失败归因
-
-- build 失败:归因到前端依赖或 TypeScript 类型。
-- API base URL 缺失:开发期显示配置错误页,不静默请求空地址。
-- CORS 失败:归因到 FastAPI CORS 配置,不归因到页面组件。
-
-## Web-C:Run 列表与总览
-
-### 目标
-
-完成 Web 第一屏:Run 列表、Run 总览、顶部 7 阶段 Pipeline。
-
-### 页面
-
-- `/`
-- `/runs`
-- `/runs/[runId]`
-
-### 组件
-
-- `RunList`
-- `RunStatusBadge`
-- `RunOverviewHeader`
-- `PipelineHeader`
-- `PipelineStageCard`
-- `DataOriginBadge`
-
-### 数据来源
-
-- `GET /runs`
-- `GET /runs/{run_id}/dashboard`
-
-### show 视觉参考
-
-保留:
-
-- 顶部 7 阶段横向结构。
-- 阶段状态颜色。
-- 左列表右详情的信息密度。
-
-替换:
-
-- 静态 scenario 数据。
-- 手写的 run 成功样例。
-
-### 验证命令
-
-```bash
-cd web
-npm run lint
-npm run build
-```
-
-后续有 Playwright 后增加:
-
-```bash
-cd web
-npm run test:e2e
-```
-
-## Web-D:阶段详情页面
-
-### 目标
-
-实现真实 run 的阶段详情展示。
-
-### 功能范围
-
-数据源阶段:
-
-- `source_context`
-- `pattern_seed_pack`
-- evidence pack 摘要
-
-Query 阶段:
-
-- query 文本
-- generation method
-- LLM 变体来源
-- query effect status
-
-Platform / 发现内容阶段:
-
-- discovered content
-- media record
-- pattern recall evidence
-
-判断阶段:
-
-- rule decision
-- hard gate
-- scorecard
-- threshold action
-
-游走阶段:
-
-- walk action
-- source path
-- query next page / author / tag 来源
-
-资产沉淀阶段:
-
-- final output
-- author assets
-- search clues
-
-策略学习阶段:
-
-- strategy review
-- regeneration action
-
-### 数据来源
-
-- `GET /runs/{run_id}/queries`
-- `GET /runs/{run_id}/content-items`
-- `GET /runs/{run_id}/timeline`
-- `GET /runs/{run_id}/final-output`
-- `GET /runs/{run_id}/strategy-review`
-- `GET /runs/{run_id}/runtime-files/{filename}`
-
-### 验证重点
-
-- 缺文件显示“缺失”,不白屏。
-- DB 与 runtime 混合时显示 `mixed_with_runtime_export`。
-- 失败 run 也能展示错误定位。
-- pending / failed / rule_blocked / partial_success 状态不被错误翻译。
-
-## Web-E:视觉迁移与交互对齐
-
-### 目标
-
-让新 Web 看起来和 `show` 的核心交互一致,但组件和数据层全部重写。
-
-### 保留视觉
-
-- PipelineHeader
-- DataSourceStageBrowser
-- QueryWorkshop
-- JudgeRulePackBoard
-- WalkStrategyWorkbench
-- AssetFlowBoard
-- StrategyLearningBoard
-
-### 删除或隐藏
-
-- 小红书
-- 快手
-- B站
-- 视频号
-- 票圈
-- 热点
-- 养号
-- 共创作者
-- 相似作者
-- 历史作者库
-- Case 独立数据源
-
-这些只允许作为旧 show 入口或未来能力说明,不进入 V1 默认展示。
-
-### 验收方式
-
-- 桌面视口截图。
-- 移动视口基础可读。
-- 文本不溢出按钮和卡片。
-- 左列表右详情切换不抖动。
-- 抽屉打开后信息完整可读。
-
-## Web-F:运行回放与调试页
-
-### 目标
-
-提供 runtime / validation 调试页,给工程排错使用,不作为业务事实主视图。
-
-### 功能
-
-- runtime file 列表。
-- runtime JSON / JSONL 查看。
-- validation findings。
-- file status。
-- data origin badge。
-
-### 安全边界
-
-- 文件名必须来自白名单。
-- 不接受绝对路径。
-- 不接受 `../`。
-- 不展示 token / password / cookie / authorization。
-
-### 验证命令
-
-```bash
-rg -n "PASSWORD=|TOKEN=|API_KEY=|SECRET=|Authorization:|Cookie:" web
-```
-
-期望没有真实敏感赋值。
-
-## 总体验收
-
-开发完成后建议执行:
-
-```bash
-uv run pytest tests/test_api.py -q
-uv run python scripts/validate_schema_registry.py
-uv run --with pymysql python scripts/validate_content_agent_db.py --env-file .env
-cd web && npm run lint && npm run build
-```
-
-如果已有 Playwright:
-
-```bash
-cd web && npm run test:e2e
-```
-
-## 开发纪律
-
-- 先补后端只读聚合 API,再做前端页面。
-- 前端只读 API,不直接查 DB。
-- show 只做视觉参考,不做数据依赖。
-- runtime 回放必须和生产事实分开标识。
-- 不为了页面方便新增模糊 `utils.ts`;格式化、状态映射、API adapter 分目录维护。
-- 不把 P6 之外的未来边能力默认展示成已实现。

+ 0 - 37
web/docs/06_WebV2迭代计划.md

@@ -1,37 +0,0 @@
-# Web V2 迭代计划(权威版)
-
-状态:本文是 Web V2 的权威迭代计划,基于 CFA V2 已交付事实(M3=42d0802 / M4=410cd72 / M5=7e86c9a / M6=ed13f10,2026-06-10)与 web 骨架现状(6ee0079 后端 / c6c5e39 前端)。数据合同均经真实 mock run 实测。
-
-## 拍板
-
-- run 异步化与运行中进度轮询:**本轮不做**(POST /runs 同步阻塞;留待后续迭代)。
-- 回放 diff 视图:**推迟**(M0 回放是 pytest 设施,无生产导出配套);本轮只保持 data_origin 来源标注。
-- 配置只读 = 3 个全局端点(/config/rule-packs、/config/walk-strategy、/config/query-prompts);**只读不编辑**(编辑走 Excel→JSON 工具链,可视化编辑留 Web V3)。
-- 不引前端测试框架;前端验证 = `tsc --noEmit` + `next build` + 手动核验;后端新端点用 pytest TestClient。
-- 不做甘特图(与 CFA 06 计划 V3 留白一致);阶段耗时用纯 CSS 横条。
-- 前端不得发明"卡住"判断(M6 红线):只呈现耗时/失败/限流/decode 事实。
-
-## W2 后端(content_agent/api.py、schemas.py、dashboard_service.py、tests/test_api.py)
-
-1. 3 个全局只读配置端点(合同见 02 文档第 8 节);schemas 加 ConfigFileResponse{source_file, data}。
-2. dashboard_service `_query_decision_summaries` 的 `content_effect_status` 读取修复:改为 `search_query_effect_status or content_effect_status`(decision 记录从无 content_effect_status 字段,原代码恒读 None;与 M6C 对 `_effect_status_counts` 的修法一致)。
-3. `_walk_graph()` edge 增补 `rule_pack_executed` / `executed_rule_pack_id`(取自 walk action raw_payload.rule_pack_execution)——支撑"归属包 vs 实际执行包"展示。
-4. tests/test_api.py 追加配置端点与修复断言。
-
-## W3 时间线+日志页(web/app/runs/[runId]/timeline + features/runs/RunTimelinePage.tsx)
-
-数据合同(实测):timeline.summary 七字段;items[i] = {source, stage, event_type, status, timestamp, error_code, walk_action_id, record};阶段事件细节在 record.raw_payload(stage/started_at/ended_at/duration_ms/attempt)。
-
-页面:①summary 七字段 MetricCard 区;②stage_duration_ms 横条(12 阶段,stage_failed 红色高亮+error_code);③事件流列表(来源过滤 chips:阶段/decode/游走/路径;每条可展开 record.raw_payload 即日志明细;decode 按 platform_content_id 分组);④兜底链接到 runtime-files 的 run_events.jsonl 分页原文。types.ts 给 TimelineResponse 补 summary 强类型。dashboard 页移除零消费的 timeline state,改为时间线入口卡片。
-
-## W4 配置只读页(web/app/config + features/config/ConfigPage.tsx)
-
-三 tab:①规则包(dispatch 5 条含 future 标注/hard_gates 10 条/scorecard 维度与规则/effect_status_mapping 5 条/reason codes 9 条);②游走策略(binding 8 条/edge catalog 10 条/budget/stop policy);③Query Prompts(douyin/V1 profile 全字段)。AppShell 导航加"配置"入口。dashboard walk 视图 edge 展示归属包与执行包(executed=false 标"已归属未运行")。
-
-## W5 V1 bug 修复
-
-①timeline 零消费(由 W3 解决);③pipeline.ts 改用 stage_conclusions[].status;④status.ts 补 stage_*/decode/skipped 状态映射;⑤optionalRuntimeFile 吞错改为 console.warn+UI"文件加载失败";⑥rulePackLabel fallback 显示原始 id 并补四个 future 包中文名;⑦statusOptions 注明取值来源,列表页补展示 validation_status。(原清单中"patternSeed null 崩溃"复核为已安全,移除。)
-
-## 验收
-
-后端:uv run pytest -q(基线 299+新增);前端:tsc --noEmit + next build;手动:uvicorn + npm run dev,mock run 后核对时间线页/配置页/dashboard;红线:rg stall_flag/stalls 零命中,不触碰 product_documents/sql/runtime_files.py。

+ 0 - 102
web/features/config/ConfigPage.tsx

@@ -1,102 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useState } from "react";
-import { useRouter } from "next/navigation";
-import {
-  getConfigQueryPrompts,
-  getConfigRulePacks,
-  getConfigWalkPolicy,
-  getConfigWalkStrategy
-} from "@/lib/api/client";
-import type { ConfigFileResponse } from "@/lib/api/types";
-import { AppShell } from "@/components/layout/AppShell";
-import { RulePacksTab, WalkStrategyTab, WalkPolicyTab, QueryPromptsTab } from "@/features/config/configTables";
-
-const TABS = [
-  { id: "rule-packs", label: "规则包" },
-  { id: "walk-strategy", label: "游走策略" },
-  { id: "walk-policy", label: "游走护栏(walk-policy)" },
-  { id: "query-prompts", label: "Query Prompts" }
-] as const;
-
-type TabId = (typeof TABS)[number]["id"];
-
-export function ConfigPage() {
-  const router = useRouter();
-  const [tab, setTab] = useState<TabId>("rule-packs");
-  const [responses, setResponses] = useState<Partial<Record<TabId, ConfigFileResponse>>>({});
-  const [error, setError] = useState<string | null>(null);
-  const [loading, setLoading] = useState(true);
-
-  const load = useCallback(async () => {
-    setLoading(true);
-    setError(null);
-    try {
-      const [rulePacks, walkStrategy, walkPolicy, queryPrompts] = await Promise.all([
-        getConfigRulePacks(),
-        getConfigWalkStrategy(),
-        getConfigWalkPolicy(),
-        getConfigQueryPrompts()
-      ]);
-      setResponses({
-        "rule-packs": rulePacks,
-        "walk-strategy": walkStrategy,
-        "walk-policy": walkPolicy,
-        "query-prompts": queryPrompts
-      });
-    } catch (err) {
-      setError(err instanceof Error ? err.message : "配置加载失败");
-    } finally {
-      setLoading(false);
-    }
-  }, []);
-
-  useEffect(() => {
-    void load();
-  }, [load]);
-
-  const current = responses[tab];
-
-  // 回退到来时的页面(通常是某个 run 的「策略配置」);无历史则兜底回 /runs。
-  const goBack = () => {
-    if (typeof window !== "undefined" && window.history.length > 1) {
-      router.back();
-    } else {
-      router.push("/runs");
-    }
-  };
-
-  return (
-    <AppShell onRefresh={load} onBack={goBack}>
-      <section className="detail-panel">
-        <header className="detail-header">
-          <div>
-            <h1 className="detail-title">配置即真相(只读)</h1>
-            <p className="muted">
-              运行时读取的 JSON 配置原文。只读展示;编辑走 Excel→JSON 工具链(可视化编辑留 Web V3)。
-            </p>
-          </div>
-          {current ? <span className="muted">{current.source_file}</span> : null}
-        </header>
-        <div className="filter-bar">
-          {TABS.map((option) => (
-            <button
-              key={option.id}
-              type="button"
-              className={`chip${tab === option.id ? " active" : ""}`}
-              onClick={() => setTab(option.id)}
-            >
-              {option.label}
-            </button>
-          ))}
-        </div>
-        {loading ? <div className="loading-state">配置加载中…</div> : null}
-        {error ? <div className="error-state">{error}</div> : null}
-        {current && tab === "rule-packs" ? <RulePacksTab data={current.data} /> : null}
-        {current && tab === "walk-strategy" ? <WalkStrategyTab data={current.data} /> : null}
-        {current && tab === "walk-policy" ? <WalkPolicyTab data={current.data} /> : null}
-        {current && tab === "query-prompts" ? <QueryPromptsTab data={current.data} /> : null}
-      </section>
-    </AppShell>
-  );
-}

+ 0 - 310
web/features/config/configTables.tsx

@@ -1,310 +0,0 @@
-"use client";
-
-// 配置渲染公共件:从 ConfigPage 抽出,供 /config 页与 run 工作台「策略配置」面板共用。
-import { FactGrid } from "@/components/cards/FactGrid";
-import { fieldAnnotation } from "@/lib/config/fieldLabels";
-
-// walk_policy 的拍板值可能裸值,也可能带 {value, provenance, tbd} 留痕包裹。
-export function unwrap(value: unknown): unknown {
-  if (value && typeof value === "object" && "value" in (value as Record<string, unknown>)) {
-    return (value as Record<string, unknown>).value;
-  }
-  return value;
-}
-
-export function provenanceOf(value: unknown): string {
-  if (value && typeof value === "object" && "provenance" in (value as Record<string, unknown>)) {
-    return String((value as Record<string, unknown>).provenance || "");
-  }
-  return "";
-}
-
-export function cell(value: unknown): string {
-  const v = unwrap(value);
-  if (v == null || v === "") return "—";
-  if (typeof v === "boolean") return v ? "是" : "否";
-  if (typeof v === "object") return JSON.stringify(v, null, 0);
-  return String(v);
-}
-
-export function asRows(value: unknown): Array<Record<string, unknown>> {
-  return Array.isArray(value) ? (value as Array<Record<string, unknown>>) : [];
-}
-
-export function asRecord(value: unknown): Record<string, unknown> {
-  return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
-}
-
-export function ConfigTable({
-  title,
-  note,
-  rows,
-  fields
-}: {
-  title: string;
-  note?: string;
-  rows: Array<Record<string, unknown>>;
-  fields: string[];
-}) {
-  return (
-    <section className="section">
-      <h3 className="section-title">
-        {title} <span className="muted">{rows.length} 条</span>
-      </h3>
-      {note ? <p className="muted">{note}</p> : null}
-      {rows.length === 0 ? (
-        <div className="empty-state">无条目。</div>
-      ) : (
-        <div className="config-table-wrap">
-          <table className="config-table">
-            <thead>
-              <tr>
-                {fields.map((field) => {
-                  const annotation = fieldAnnotation(field);
-                  return (
-                    <th key={field} title={annotation.desc}>
-                      {annotation.label}
-                      {annotation.label !== field ? <small>{field}</small> : null}
-                    </th>
-                  );
-                })}
-              </tr>
-            </thead>
-            <tbody>
-              {rows.map((row, index) => (
-                <tr key={index}>
-                  {fields.map((field) => (
-                    <td key={field}>{cell(row[field])}</td>
-                  ))}
-                </tr>
-              ))}
-            </tbody>
-          </table>
-          <details className="field-annotations">
-            <summary className="muted">字段业务含义说明</summary>
-            <dl>
-              {fields.map((field) => {
-                const annotation = fieldAnnotation(field);
-                return (
-                  <div key={field}>
-                    <dt>
-                      {annotation.label}
-                      <small>{field}</small>
-                    </dt>
-                    <dd>{annotation.desc || "—"}</dd>
-                  </div>
-                );
-              })}
-            </dl>
-          </details>
-        </div>
-      )}
-    </section>
-  );
-}
-
-export function RulePacksTab({ data }: { data: Record<string, unknown> }) {
-  const dispatch = asRows(data.rule_pack_dispatch);
-  const packs = asRows(data.rule_packs);
-  return (
-    <>
-      <ConfigTable
-        title="Dispatch(按对象选择规则包)"
-        note="dispatch_enabled=否 即 future 包:已归属未运行(M3 拍板,启用需打开 dispatch)。"
-        rows={dispatch}
-        fields={["dispatch_id", "target_entity", "content_format", "runtime_stage", "dispatch_enabled", "rule_pack_id", "rule_pack_version", "notes"]}
-      />
-      {packs.map((pack, i) => {
-        const p = asRecord(pack);
-        const scorecard = asRecord(p.scorecard);
-        const packId = String(p.rule_pack_id || `包 ${i + 1}`);
-        return (
-          <div key={packId}>
-            <ConfigTable
-              title={`Hard Gates(${packId})`}
-              note="动作 / 严重度全部配置驱动;V3 新增 not_fit_senior / low_confidence / judge_failed(Gemini 判定门槛)。"
-              rows={asRows(p.hard_gates)}
-              fields={["gate_id", "label", "when", "decision_action", "severity", "stop_scoring", "priority"]}
-            />
-            <ConfigTable
-              title="Scorecard 维度"
-              note="V3 实际打分 = relevance(60)+ platform_heat(40);runtime_status=deprecated 的旧 5 维已弃用,仅留档。"
-              rows={asRows(scorecard.dimensions)}
-              fields={["key", "label", "max_score", "weight_percent", "runtime_status"]}
-            />
-            <ConfigTable
-              title="Scorecard 评分规则"
-              rows={asRows(scorecard.scoring_rules)}
-              fields={["scoring_rule_id", "dimension_key", "field_path", "operator", "expected_value", "score_value", "enabled"]}
-            />
-            <ConfigTable
-              title="阈值(Thresholds)"
-              rows={asRows(p.thresholds)}
-              fields={["min_score", "max_score", "decision_action", "decision_reason_code", "effect_status", "enabled"]}
-            />
-          </div>
-        );
-      })}
-      <ConfigTable
-        title="效果状态映射(effect_status_mapping)"
-        rows={asRows(data.effect_status_mapping)}
-        fields={["mapping_id", "decision_action", "reason_category", "is_hard_gate", "content_effect_status", "query_effect_status", "priority", "enabled", "notes"]}
-      />
-      <ConfigTable
-        title="决策原因码"
-        rows={asRows(data.decision_reason_codes)}
-        fields={["decision_reason_code", "reason_label", "reason_category", "is_hard_gate", "business_explanation"]}
-      />
-    </>
-  );
-}
-
-const GATE_ACTION_ROWS: Array<{ key: string; cn: string }> = [
-  { key: "ADD_TO_CONTENT_POOL", cn: "入池" },
-  { key: "KEEP_CONTENT_FOR_REVIEW", cn: "复看" },
-  { key: "REJECT_CONTENT", cn: "淘汰" }
-];
-
-export function WalkPolicyTab({ data }: { data: Record<string, unknown> }) {
-  const global = asRecord(data.global);
-  const perm = asRecord(data.edge_permissions);
-  const budgets = asRows(data.edge_budgets);
-  const tiers = asRecord(data.budget_tiers);
-  const dedup = asRecord(data.dedup);
-
-  const controlledEdges = new Set<string>();
-  GATE_ACTION_ROWS.forEach(({ key }) => {
-    const row = asRecord(perm[key]);
-    Object.keys(row).forEach((edge) => {
-      if (!edge.startsWith("_") && edge !== "note") controlledEdges.add(edge);
-    });
-  });
-  const edgeCols = [...controlledEdges];
-
-  return (
-    <>
-      <section className="section">
-        <h3 className="section-title">全局护栏(global)</h3>
-        <FactGrid
-          rows={Object.entries(global).map(([k, v]) => {
-            const prov = provenanceOf(v);
-            return [`${fieldAnnotation(k).label}(${k})`, prov ? `${cell(v)} — ${prov}` : cell(v)];
-          })}
-        />
-      </section>
-
-      <section className="section">
-        <h3 className="section-title">判定门控矩阵(edge_permissions)</h3>
-        <p className="muted">
-          判定动作 → 各受控出边的放行结果。hashtag_to_query 随 video_to_hashtag 链放行;其余 6 条边为导航 / 系统边,无判定门控。
-        </p>
-        <div className="config-table-wrap">
-          <table className="config-table">
-            <thead>
-              <tr>
-                <th>判定动作</th>
-                {edgeCols.map((edge) => (
-                  <th key={edge}>{edge}</th>
-                ))}
-              </tr>
-            </thead>
-            <tbody>
-              {GATE_ACTION_ROWS.map(({ key, cn }) => {
-                const row = asRecord(perm[key]);
-                return (
-                  <tr key={key}>
-                    <td>
-                      {cn}
-                      <small>{key}</small>
-                    </td>
-                    {edgeCols.map((edge) => {
-                      const v = String(unwrap(row[edge]) || "—");
-                      const cls =
-                        v === "allow" ? "gate-allow" : v === "allow_low_budget" ? "gate-low" : v === "deny" ? "gate-deny" : "";
-                      const vCn =
-                        v === "allow" ? "放行" : v === "allow_low_budget" ? "低预算" : v === "deny" ? "拦截" : v;
-                      return (
-                        <td key={edge} className={cls}>
-                          {vCn}
-                        </td>
-                      );
-                    })}
-                  </tr>
-                );
-              })}
-            </tbody>
-          </table>
-        </div>
-      </section>
-
-      <ConfigTable
-        title="边预算(edge_budgets)"
-        note="对齐 v1 实际硬限;provenance 留拍板来源,理想值待 M7 标定。"
-        rows={budgets}
-        fields={["edge_id", "max_total_actions", "max_per_query", "max_works_per_author", "max_per_content", "max_pages", "max_tag_hops", "provenance"]}
-      />
-
-      <section className="section">
-        <h3 className="section-title">预算档位(budget_tiers)与去重(dedup)</h3>
-        <FactGrid
-          rows={[
-            ...Object.entries(tiers).map(([k, v]) => [`tier · ${k}`, cell(v)] as [string, string]),
-            ...Object.entries(dedup).map(([k, v]) => [`dedup · ${k}`, cell(v)] as [string, string])
-          ]}
-        />
-      </section>
-    </>
-  );
-}
-
-export function WalkStrategyTab({ data }: { data: Record<string, unknown> }) {
-  return (
-    <>
-      <ConfigTable
-        title="边→规则包显式绑定(walk_rule_pack_binding)"
-        note="仅 decision_to_asset → 内容包(advisory);其余边的判定门控见「游走护栏」tab 的 edge_permissions。"
-        rows={asRows(data.walk_rule_pack_binding)}
-        fields={["binding_id", "edge_id", "target_entity", "rule_pack_id", "dispatch_policy", "required", "notes"]}
-      />
-      <ConfigTable
-        title="游走边目录(walk_edge_catalog)"
-        note="共 10 条边;edge_type=navigate/reseed/stop/budget;门控与预算见「游走护栏」tab。"
-        rows={asRows(data.walk_edge_catalog)}
-        fields={["edge_id", "edge_type", "from_node_type", "to_node_type", "priority", "can_loop", "notes"]}
-      />
-    </>
-  );
-}
-
-export function QueryPromptsTab({ data }: { data: Record<string, unknown> }) {
-  const profiles = asRecord(data.profiles);
-  return (
-    <>
-      {Object.entries(profiles).map(([name, raw]) => {
-        const profile = asRecord(raw);
-        const genericFilter = asRecord(profile.generic_filter);
-        return (
-          <section className="section" key={name}>
-            <h3 className="section-title">Profile · {name}</h3>
-            <FactGrid
-              rows={[
-                [`${fieldAnnotation("prompt_version").label}(prompt_version)`, profile.prompt_version],
-                [`${fieldAnnotation("temperature").label}(temperature)`, `${cell(profile.temperature)} — ${fieldAnnotation("temperature").desc}`],
-                [`${fieldAnnotation("max_tokens").label}(max_tokens)`, profile.max_tokens],
-                [`${fieldAnnotation("variants_per_seed").label}(variants_per_seed)`, `${cell(profile.variants_per_seed)} — ${fieldAnnotation("variants_per_seed").desc}`],
-                [`${fieldAnnotation("evidence_fields").label}(evidence_fields)`, `${(profile.evidence_fields as unknown[] | undefined)?.length || 0} 个字段 — ${fieldAnnotation("evidence_fields").desc}`],
-                [`${fieldAnnotation("generic_filter").label} queries`, ((genericFilter.queries as unknown[]) || []).join("、")],
-                [`${fieldAnnotation("generic_filter").label} tokens`, ((genericFilter.tokens as unknown[]) || []).join("、")]
-              ]}
-            />
-            <details>
-              <summary className="muted">system / user prompt 原文(逐字复刻,运行真相源)</summary>
-              <pre className="event-payload">{cell(profile.system)}</pre>
-              <pre className="event-payload">{cell(profile.user)}</pre>
-            </details>
-          </section>
-        );
-      })}
-    </>
-  );
-}

+ 0 - 330
web/features/runs/ContentAssetShelf.tsx

@@ -1,330 +0,0 @@
-"use client";
-
-// 内容资产「成品货架」:把本次入池(ADD_TO_CONTENT_POOL)的视频以交付清单形式摆出来。
-// 每张卡 = 可播放 iframe + 来历 + Gemini 判定 + 抖音真实热度 + 打开原帖,
-// 卡底预留「我方平台实测表现」位(入库后回填,与 Gemini 预测对照)。数据全部来自已加载的 content-items,无新增取数。
-import { useEffect, useState, type ReactNode } from "react";
-import { Eye, ExternalLink, Lock, MessageCircle, PlayCircle, Share2, ShieldCheck, Star, ThumbsUp } from "lucide-react";
-import { contentUrl, embedPlayerUrl, HEAT_FIELD_LABELS, platformLabel } from "@/lib/platform/content";
-import { getConfigPlatforms } from "@/lib/api/client";
-import type { ContentItemsResponse, PlatformDescriptor } from "@/lib/api/types";
-
-type AnyRec = Record<string, unknown>;
-
-const POOLED = "ADD_TO_CONTENT_POOL";
-
-const REVIEW = "KEEP_CONTENT_FOR_REVIEW";
-
-// 统一互动指标键 → 图标(标签走 HEAT_FIELD_LABELS,均由平台 profile 的 heat 字段驱动)。
-const HEAT_ICON: Record<string, ReactNode> = {
-  digg_count: <ThumbsUp size={13} />,
-  comment_count: <MessageCircle size={13} />,
-  share_count: <Share2 size={13} />,
-  collect_count: <Star size={13} />,
-  play_count: <Eye size={13} />
-};
-
-// 待复看「为何待复看」文案:决策原因码 + 触发的拦截规则(与规则包配置口径一致)。
-const REASON_CODE_CN: Record<string, string> = {
-  content_judge_failed: "Gemini 判定失败,转人工复看",
-  content_score_review: "评分落入复看区,需人工裁决"
-};
-const BLOCKING_RULE_CN: Record<string, string> = {
-  not_fit_senior: "不适合 50 岁以上人群",
-  low_confidence: "Gemini 判定信心不足",
-  judge_failed: "Gemini 判定失败"
-};
-
-function decisionAction(it: AnyRec): string {
-  const rd = (it.rule_decision as AnyRec) || {};
-  const pmr = (it.pattern_match_result as AnyRec) || {};
-  return String(rd.decision_action || pmr.decision_action || "");
-}
-
-// 数字 → 人话:≥1万 显示 X.X万,否则千分位。
-function fmtStat(n: unknown): string {
-  const v = Number(n);
-  if (!Number.isFinite(v)) return "—";
-  if (v >= 10000) return `${(v / 10000).toFixed(1)}万`;
-  return v.toLocaleString("en-US");
-}
-
-function num(v: unknown): number | null {
-  if (v === null || v === undefined || v === "") return null; // Number(null)===0,需先挡掉,否则缺失值会显示成 0
-  const n = Number(v);
-  return Number.isFinite(n) ? n : null;
-}
-
-// 单卡视频:懒加载 iframe(仅抖音有 embed),无 ID 不渲染。
-function AssetMedia({ item }: { item: AnyRec }) {
-  const [on, setOn] = useState(false);
-  const id = String(item.platform_content_id || "");
-  if (!id) return null;
-  const pLabel = platformLabel(item.platform);
-  const playerUrl = embedPlayerUrl(item);
-  return (
-    <div className="asset-media">
-      {!playerUrl ? (
-        <div className="asset-media-frame asset-media-empty muted">{pLabel}无公开嵌入播放器</div>
-      ) : on ? (
-        <iframe
-          className="asset-media-frame"
-          src={playerUrl}
-          title={`${pLabel}视频 ${id}`}
-          loading="lazy"
-          allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
-          allowFullScreen
-        />
-      ) : (
-        <button type="button" className="asset-media-frame asset-media-play" onClick={() => setOn(true)}>
-          <PlayCircle size={30} />
-          <span>播放</span>
-        </button>
-      )}
-    </div>
-  );
-}
-
-// 互动数据行:只显示该平台真实可得的指标(profile.heat_fields 驱动);
-// 抖音=赞/评/分享/收藏,视频号=只点赞,快手=含播放…缺指标不显示「0」。无 heat 字段则整行不渲染。
-function HeatStats({ item, descriptor }: { item: AnyRec; descriptor?: PlatformDescriptor }) {
-  const st = (item.statistics as AnyRec) || {};
-  const fields = descriptor?.heat_fields || [];
-  if (!fields.length) return null;
-  const label = platformLabel(item.platform);
-  return (
-    <div className="asset-stats">
-      <span className="asset-stats-label">{label}热度</span>
-      {fields.map((f) => (
-        <span className="asset-stat" key={f} title={HEAT_FIELD_LABELS[f] || f}>
-          {HEAT_ICON[f] || null}
-          <em>{HEAT_FIELD_LABELS[f] || f}</em>
-          {fmtStat(st[f])}
-        </span>
-      ))}
-    </div>
-  );
-}
-
-function AssetCard({
-  item,
-  descriptor,
-  mode
-}: {
-  item: AnyRec;
-  descriptor?: PlatformDescriptor;
-  mode: "pooled" | "review";
-}) {
-  const pmr = (item.pattern_match_result as AnyRec) || {};
-  const rd = (item.rule_decision as AnyRec) || {};
-  const title = String(item.description || item.title || `${platformLabel(item.platform)}视频`);
-  const author = String(item.author_display_name || item.platform_author_id || "未知作者");
-  const queries = (item.matched_search_queries as unknown[]) || [];
-  const query = queries.length ? String(queries[0]) : "";
-  const fit = pmr.fit_senior_50plus;
-  const conf = num(pmr.fit_confidence);
-  const rel = num(pmr.relevance_score);
-  const score = num(rd.score);
-  const reason = String(pmr.reason || "");
-  const tags = ((item.tags as unknown[]) || []).map((t) => String(t)).slice(0, 6);
-  const origin = contentUrl(item);
-  const fitOk = fit === true;
-  const judgeFailed = String(pmr.judge_status || "") === "failed";
-  const isReview = mode === "review";
-  const reasonCode = String(rd.decision_reason_code || "");
-  const blocking = ((rd.triggered_blocking_rules as unknown[]) || []).map((r) => String(r));
-
-  return (
-    <article className="asset-card">
-      <div className="asset-card-media">
-        <AssetMedia item={item} />
-      </div>
-      <div className="asset-card-body">
-        <div className="asset-card-head">
-          <strong className="asset-card-title" title={title}>
-            {title}
-          </strong>
-          <span className={`asset-status ${isReview ? "review" : "pooled"}`}>{isReview ? "待复看" : "已入池"}</span>
-        </div>
-        <div className="asset-card-sub">
-          <span>{author}</span>
-          {query ? <span className="asset-from">来自搜索「{query}」</span> : null}
-        </div>
-
-        {/* 待复看:为何没自动入池(决策原因 + 触发的拦截规则)——复看时最该先看的 */}
-        {isReview ? (
-          <div className="asset-why">
-            <span className="asset-why-tag">为何待复看</span>
-            <span className="asset-why-reason">{REASON_CODE_CN[reasonCode] || reasonCode || "需人工复看"}</span>
-            {blocking.map((r) => (
-              <span className="asset-why-rule" key={r}>
-                {BLOCKING_RULE_CN[r] || r}
-              </span>
-            ))}
-          </div>
-        ) : null}
-
-        <div className="asset-judge">
-          <span className="asset-judge-tag">
-            <ShieldCheck size={13} /> Gemini 判定
-          </span>
-          {judgeFailed ? (
-            <span className="asset-judge-fit no">判定失败</span>
-          ) : (
-            <>
-              <span className={`asset-judge-fit ${fitOk ? "ok" : "no"}`}>
-                适合 50+ {fit === true ? "是" : fit === false ? "否" : "—"}
-              </span>
-              <span className="asset-judge-num">置信 {conf == null ? "—" : conf.toFixed(2)}</span>
-              <span className="asset-judge-num">相关 {rel == null ? "—" : rel.toFixed(2)}</span>
-            </>
-          )}
-          {score != null ? (
-            <span className="asset-judge-score">{score} 分</span>
-          ) : isReview ? (
-            <span className="asset-judge-score missing">评分缺失</span>
-          ) : null}
-        </div>
-
-        {reason ? <p className={`asset-reason${judgeFailed ? " err" : ""}`}>{reason}</p> : null}
-
-        <HeatStats item={item} descriptor={descriptor} />
-
-        {tags.length ? (
-          <div className="asset-tags">
-            {tags.map((t, i) => (
-              <span className="asset-tag" key={i}>
-                {t}
-              </span>
-            ))}
-          </div>
-        ) : null}
-
-        {origin ? (
-          <div className="asset-actions">
-            <a className="text-button" href={origin} target="_blank" rel="noreferrer">
-              <ExternalLink size={14} /> 打开原帖
-            </a>
-          </div>
-        ) : null}
-
-        {isReview ? (
-          /* 预留:人工复看裁决(转入池 / 淘汰)——写回管线接好后启用 */
-          <div className="asset-perf review">
-            <div className="asset-perf-head">
-              <Lock size={12} /> 人工复看裁决
-              <span className="asset-perf-flag">复看后回填</span>
-            </div>
-            <div className="asset-verdict-actions">
-              <span className="asset-verdict-btn pool">转入池</span>
-              <span className="asset-verdict-btn reject">淘汰</span>
-              <span className="asset-verdict-btn rejudge">重新判定</span>
-            </div>
-            <p className="asset-perf-note">
-              {judgeFailed
-                ? "本条 Gemini 判定技术失败(非内容不合格),建议重判或人工确认后再决定去留。"
-                : "判定通过但评分未完成,需人工确认后转入池或淘汰。"}
-            </p>
-          </div>
-        ) : (
-          /* 预留:我方平台实测表现(入库后回填,与 Gemini 预测对照)*/
-          <div className="asset-perf">
-            <div className="asset-perf-head">
-              <Lock size={12} /> 我方平台实测表现
-              <span className="asset-perf-flag">入库后回填</span>
-            </div>
-            <div className="asset-perf-metrics">
-              {["曝光", "播放", "完播率", "互动", "转化"].map((m) => (
-                <span className="asset-perf-cell" key={m}>
-                  <em>{m}</em>
-                  <b>—</b>
-                </span>
-              ))}
-            </div>
-            <p className="asset-perf-note">
-              上方是 Gemini 的<strong>预测</strong>;上线后用真实数据验证「适合 50+」是否成立,并回流到学习复盘。
-            </p>
-          </div>
-        )}
-      </div>
-    </article>
-  );
-}
-
-export function ContentAssetShelf({
-  contentItems,
-  kept,
-  review,
-  rejected
-}: {
-  contentItems: ContentItemsResponse;
-  kept: number | string;
-  review: number | string;
-  rejected: number | string;
-}) {
-  const items = (contentItems.items as AnyRec[]) || [];
-  const pooled = items.filter((it) => decisionAction(it) === POOLED);
-  const reviewItems = items.filter((it) => decisionAction(it) === REVIEW);
-  // 平台展示目录(label + 每平台真实互动指标),加平台只改 profile JSON、前端零改动。
-  const [catalog, setCatalog] = useState<Record<string, PlatformDescriptor>>({});
-  const [tab, setTab] = useState<"pooled" | "review">("pooled");
-  useEffect(() => {
-    let alive = true;
-    getConfigPlatforms()
-      .then((r) => alive && setCatalog(r.platforms || {}))
-      .catch(() => {});
-    return () => {
-      alive = false;
-    };
-  }, []);
-
-  const list = tab === "pooled" ? pooled : reviewItems;
-  const emptyText =
-    tab === "pooled"
-      ? "本次没有入池资产(成品)。可切到「待复看」查看待裁决的内容。"
-      : "本次没有待复看内容。";
-
-  return (
-    <div className="asset-shelf">
-      <div className="asset-summary">
-        <button
-          type="button"
-          className={`asset-tabbtn pooled ${tab === "pooled" ? "active" : ""}`}
-          onClick={() => setTab("pooled")}
-        >
-          <span>入池</span>
-          <strong>{kept}</strong>
-        </button>
-        <button
-          type="button"
-          className={`asset-tabbtn review ${tab === "review" ? "active" : ""}`}
-          onClick={() => setTab("review")}
-        >
-          <span>待复看</span>
-          <strong>{review}</strong>
-        </button>
-        <span className="asset-summary-reject">淘汰 {rejected}</span>
-        <p className="asset-summary-note">
-          {tab === "pooled"
-            ? "「入池」=本次交付的成品;逐条可播放、看判定与平台互动热度。"
-            : "「待复看」=判定未自动通过、需人工裁决的内容;每条标注了为何待复看。"}
-        </p>
-      </div>
-
-      {list.length ? (
-        <div className="asset-shelf-list">
-          {list.map((it, i) => (
-            <AssetCard
-              item={it}
-              mode={tab}
-              descriptor={catalog[String(it.platform || "").toLowerCase()]}
-              key={String(it.platform_content_id || i)}
-            />
-          ))}
-        </div>
-      ) : (
-        <div className="empty-state">{emptyText}</div>
-      )}
-    </div>
-  );
-}

+ 0 - 162
web/features/runs/DemandDetail.tsx

@@ -1,162 +0,0 @@
-"use client";
-
-// /runs 右侧:选中某条 run(= 一条需求)后,就地展示「需求说明书」——
-// 这次要满足什么需求 / 凭什么有这个需求(Pattern 证据)/ 这一趟的成果 / 进入完整链路。
-// 数据模型 1 run = 1 需求;基础名/描述来自列表项,证据与成果按需取数。
-import { useEffect, useState } from "react";
-import Link from "next/link";
-import { ArrowRight, FlaskConical, Sprout, Target } from "lucide-react";
-import { StatusBadge } from "@/components/badges/StatusBadge";
-import { getDashboard, getRuntimeFile } from "@/lib/api/client";
-import { platformLabel } from "@/lib/platform/content";
-import { compactValue } from "@/lib/status/status";
-import type { DashboardResponse, RunListItem } from "@/lib/api/types";
-
-type AnyRec = Record<string, unknown>;
-
-function rec(v: unknown): AnyRec {
-  return v && typeof v === "object" ? (v as AnyRec) : {};
-}
-
-function fmtTime(iso?: string | null): string {
-  if (!iso) return "运行时间未知";
-  const d = new Date(iso);
-  if (Number.isNaN(d.getTime())) return "运行时间未知";
-  const p = (n: number) => String(n).padStart(2, "0");
-  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
-}
-
-export function DemandDetail({ item }: { item: RunListItem }) {
-  const runId = item.run_id;
-  const [dash, setDash] = useState<DashboardResponse | null>(null);
-  const [seed, setSeed] = useState<AnyRec | null>(null);
-  const [loading, setLoading] = useState(true);
-
-  useEffect(() => {
-    let alive = true;
-    setLoading(true);
-    setDash(null);
-    setSeed(null);
-    Promise.allSettled([getDashboard(runId), getRuntimeFile(runId, "pattern_seed_pack.json", 80)]).then(
-      ([d, s]) => {
-        if (!alive) return;
-        if (d.status === "fulfilled") setDash(d.value);
-        if (s.status === "fulfilled") setSeed(rec(s.value.data));
-        setLoading(false);
-      }
-    );
-    return () => {
-      alive = false;
-    };
-  }, [runId]);
-
-  const biz = rec(dash?.business_summary);
-  const seedTerms: string[] = seed && Array.isArray(seed.seed_terms) ? seed.seed_terms.map(String) : [];
-  const matched = seed && Array.isArray(seed.matched_post_ids) ? seed.matched_post_ids.length : null;
-  // 命中分类 = 这个 Pattern(itemset)锚定的分类树节点(可能多个)
-  const bindings = seed && Array.isArray(seed.category_bindings) ? seed.category_bindings.map(rec) : [];
-  const source = compactValue(seed?.pattern_source_system) || "—";
-  // Pattern 标识 = itemset_id(频繁项集);从分类绑定里取唯一值
-  const patternIds = [...new Set(bindings.map((b) => b.itemset_id).filter((x) => x != null).map(String))];
-
-  return (
-    <div className="demand-detail">
-      <Link className="demand-enter" href={`/runs/${encodeURIComponent(runId)}`}>
-        进入完整链路(发现旅程 / 内容资产 / 学习复盘 / 策略配置)
-        <ArrowRight size={16} />
-      </Link>
-
-      <header className="demand-head">
-        <span className="demand-eyebrow"><Target size={13} /> 本次需求</span>
-        <h2 className="demand-name">{item.demand_name || "未命名需求"}</h2>
-        {item.demand_desc ? <p className="demand-desc">{item.demand_desc}</p> : null}
-        <div className="demand-badges">
-          {item.platform ? <span className="demand-chip">{platformLabel(item.platform)}</span> : null}
-          <span className="demand-chip ghost">🕒 {fmtTime(item.started_at)}</span>
-          <StatusBadge status={item.status} />
-          <span className="demand-runid">{runId}</span>
-        </div>
-      </header>
-
-      {/* 凭什么有这个需求 */}
-      <section className="demand-card">
-        <h3 className="demand-card-title"><Sprout size={15} /> 凭什么有这个需求</h3>
-        {loading ? (
-          <div className="loading-state">加载中…</div>
-        ) : seed ? (
-          <div className="demand-evidence">
-            {/* 种子词 = 这个 Pattern 命中的分类名,直接用作搜索词 */}
-            <div className="demand-ev-block">
-              <div className="demand-ev-head">
-                <span className="demand-tag pattern">种子词 · 来自 Pattern</span>
-                {patternIds.length ? <span className="demand-pid">Pattern #{patternIds.join(" + ")}(频繁项集)</span> : null}
-              </div>
-              <div className="demand-seed-terms">
-                {seedTerms.length ? seedTerms.map((t) => <span className="demand-seed-term" key={t}>{t}</span>) : <span className="muted">—</span>}
-              </div>
-              <p className="demand-tip">
-                上游 Pattern 挖掘({source})得出的频繁项集,种子词就是这个 Pattern 命中的分类名,直接作为搜索词去找内容。
-              </p>
-            </div>
-
-            {/* 命中分类 = 分类树节点 */}
-            <div className="demand-ev-block">
-              <div className="demand-ev-head">
-                <span className="demand-tag tree">命中分类 · 内容分类树节点</span>
-              </div>
-              {bindings.length ? (
-                <div className="demand-cat-list">
-                  {bindings.map((b, i) => (
-                    <div className="demand-cat-row" key={i}>
-                      <strong className="demand-cat-name">{compactValue(b.category_name)}</strong>
-                      <code className="demand-cat-path">{compactValue(b.category_path)}</code>
-                      <span className="demand-cat-meta">
-                        分类树 {compactValue(b.category_level)} 级 · {compactValue(b.category_source_type)}
-                      </span>
-                    </div>
-                  ))}
-                </div>
-              ) : (
-                <span className="muted">—</span>
-              )}
-            </div>
-
-            {/* 证据规模 / 验证 */}
-            <div className="demand-meta-row">
-              <span>证据样本 <b>{matched != null ? matched : "—"}</b> 条匹配</span>
-              <span>验证状态 <b>{compactValue(seed.validation_status) || "—"}</b></span>
-              <span>挖掘来源 <b>{source}</b></span>
-            </div>
-          </div>
-        ) : (
-          <div className="empty-state">这条 run 没有 Pattern 证据(可能是早期/冒烟数据)。</div>
-        )}
-      </section>
-
-      {/* 这一趟的成果 */}
-      <section className="demand-card">
-        <h3 className="demand-card-title"><FlaskConical size={15} /> 这一趟的成果</h3>
-        {loading ? (
-          <div className="loading-state">加载中…</div>
-        ) : dash ? (
-          <div className="demand-yield">
-            <div className="demand-yield-stat pool">
-              <span>入池</span>
-              <strong>{compactValue(biz.kept_count)}</strong>
-            </div>
-            <div className="demand-yield-stat review">
-              <span>待复看</span>
-              <strong>{compactValue(biz.review_count)}</strong>
-            </div>
-            <div className="demand-yield-stat reject">
-              <span>淘汰</span>
-              <strong>{compactValue(biz.rejected_count)}</strong>
-            </div>
-          </div>
-        ) : (
-          <div className="empty-state">暂无成果数据。</div>
-        )}
-      </section>
-    </div>
-  );
-}

+ 0 - 532
web/features/runs/RunDashboardPage.tsx

@@ -1,532 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { WalkJourney } from "@/features/runs/WalkJourney";
-import { StrategyConfigPanel } from "@/features/runs/StrategyConfigPanel";
-import { ContentAssetShelf } from "@/features/runs/ContentAssetShelf";
-
-import {
-  Activity,
-  ChevronRight,
-  FileJson,
-  GitBranch,
-  PanelRightOpen,
-  SlidersHorizontal,
-  Target
-} from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { AppShell } from "@/components/layout/AppShell";
-import { StatusBadge } from "@/components/badges/StatusBadge";
-import {
-  getContentItems,
-  getDashboard,
-  getQueries,
-  getRuntimeFile,
-  getRuntimeFiles,
-  getTimeline
-} from "@/lib/api/client";
-import type {
-  ContentItemsResponse,
-  DashboardResponse,
-  QueryListResponse,
-  RuleApplicationSummary,
-  RuntimeFileResponse,
-  RuntimeFilesResponse,
-  StageConclusion,
-  TimelineResponse,
-} from "@/lib/api/types";
-import { compactValue } from "@/lib/status/status";
-import { platformLabel } from "@/lib/platform/content";
-
-type DashboardData = {
-  dashboard: DashboardResponse;
-  queries: QueryListResponse;
-  contentItems: ContentItemsResponse;
-  timeline: TimelineResponse;
-  runtimeFiles: RuntimeFilesResponse;
-};
-
-type DrawerContent =
-  | { kind: "technical"; title: string; payload: unknown }
-  | { kind: "rule"; title: string; payload: unknown }
-  | { kind: "walk"; title: string; payload: unknown }
-  | null;
-
-export function RunDashboardPage({ runId }: { runId: string }) {
-  const [activeStage, setActiveStage] = useState("walk");
-  const [data, setData] = useState<DashboardData | null>(null);
-  const [runtimeFile, setRuntimeFile] = useState<RuntimeFileResponse | null>(null);
-  const [drawer, setDrawer] = useState<DrawerContent>(null);
-  const [error, setError] = useState<string | null>(null);
-  const [loading, setLoading] = useState(true);
-
-  const load = useCallback(async () => {
-    setLoading(true);
-    setError(null);
-    try {
-      const [dashboard, queries, contentItems, timeline, runtimeFiles] = await Promise.all([
-        getDashboard(runId),
-        getQueries(runId),
-        getContentItems(runId),
-        getTimeline(runId),
-        getRuntimeFiles(runId)
-      ]);
-      setData({ dashboard, queries, contentItems, timeline, runtimeFiles });
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err));
-    } finally {
-      setLoading(false);
-    }
-  }, [runId]);
-
-  useEffect(() => {
-    void load();
-  }, [load]);
-
-  async function openRuntimeFile(filename: string) {
-    const file = await getRuntimeFile(runId, filename, 80);
-    setRuntimeFile(file);
-    setDrawer({
-      kind: "technical",
-      title: filename,
-      payload: file.records || file.data || {}
-    });
-  }
-
-  return (
-    <AppShell
-      onRefresh={load}
-      showBack
-      centerNav={
-        data ? (
-          <PanelNav stages={data.dashboard.stage_conclusions} activeStage={activeStage} onSelect={setActiveStage} />
-        ) : null
-      }
-      toolbarLeading={
-        data ? (
-          <>
-            <PlatformChip data={data} />
-            <RunMetaInline runId={runId} timeline={data.timeline} />
-            <button
-              className="icon-button"
-              title="本次 run metadata"
-              onClick={() =>
-                setDrawer({
-                  kind: "technical",
-                  title: "本次 run metadata",
-                  payload: {
-                    summary: data.dashboard.summary,
-                    technical_refs: data.dashboard.technical_refs,
-                    validation: data.dashboard.validation,
-                    files: data.dashboard.files
-                  }
-                })
-              }
-              type="button"
-            >
-              <PanelRightOpen size={15} />
-            </button>
-          </>
-        ) : null
-      }
-    >
-      {loading ? <div className="loading-state">加载中</div> : null}
-      {error ? <div className="error-state">{error}</div> : null}
-
-      {data ? (
-        <section className="detail-panel business-page">
-          <div className="run-overview">
-            <FunnelStrip dashboard={data.dashboard} stages={data.dashboard.stage_conclusions} onSelect={setActiveStage} />
-          </div>
-          <StagePanel
-            activeStage={activeStage}
-            data={data}
-            runtimeFile={runtimeFile}
-            onOpenRuntimeFile={openRuntimeFile}
-            onOpenDrawer={setDrawer}
-          />
-        </section>
-      ) : null}
-
-      <TechnicalDetailsDrawer drawer={drawer} onClose={() => setDrawer(null)} />
-    </AppShell>
-  );
-}
-
-// run 级平台 chip(动态,取代写死的全局「Douyin V1」徽章;多数据源天然适配)。
-function PlatformChip({ data }: { data: DashboardData }) {
-  const summary = (data.dashboard.summary || {}) as Record<string, unknown>;
-  const plat = String(data.contentItems.items[0]?.platform || summary.platform || "");
-  const ver = String(summary.strategy_version || "");
-  if (!plat) return null;
-  return (
-    <span className="run-platform-chip">
-      <Activity size={13} />
-      {platformLabel(plat)}{ver ? ` · ${ver}` : ""}
-    </span>
-  );
-}
-
-// 毫秒 → 人话(运营友好):≥1min 显示 X 分 Y 秒,否则 X.Y 秒。
-function fmtDuration(ms?: number | null): string {
-  if (ms == null) return "未知";
-  const s = ms / 1000;
-  if (s < 60) return `${s.toFixed(1)} 秒`;
-  const m = Math.floor(s / 60);
-  const rem = Math.round(s % 60);
-  return rem ? `${m} 分 ${rem} 秒` : `${m} 分`;
-}
-
-// 概览条右侧:总耗时 · 事件 · 配额截断,以及完整时间线入口(明细指标收进时间线页)。
-function RunMetaInline({ runId, timeline }: { runId: string; timeline: TimelineResponse }) {
-  const summary = timeline.summary;
-  const quotaEvent = timeline.items.find((it) => it.event_type === "gemini_quota_exhausted");
-  return (
-    <div className="run-meta-inline">
-      <span>总耗时 {fmtDuration(summary?.total_duration_ms)}</span>
-      <span>事件 {timeline.total}</span>
-      {quotaEvent ? <span className="strip-warn">⚠ Gemini 配额截断</span> : null}
-      <Link className="text-button" href={`/runs/${encodeURIComponent(runId)}/timeline`}>
-        时间线 / 日志
-      </Link>
-    </div>
-  );
-}
-
-// 激进合一:被折叠的漏斗段(数据源/query/platform/judge)点击后跳「发现旅程」。
-function funnelTarget(stageId: string): string {
-  if (stageId === "source" || stageId === "query" || stageId === "platform" || stageId === "judge") return "walk";
-  return stageId;
-}
-
-// 第一层:漏斗总览条(7 段,只读 + 点击跳对应面板)。复用 stage_conclusions 与 stageCount,零新增取数。
-function FunnelStrip({
-  dashboard,
-  stages,
-  onSelect
-}: {
-  dashboard: DashboardResponse;
-  stages: StageConclusion[];
-  onSelect: (stageId: string) => void;
-}) {
-  return (
-    <div className="funnel-strip" aria-label="运行漏斗总览">
-      {stages.map((stage, i) => {
-        const count = stageCount(dashboard, stage.stage_id);
-        return (
-          <span className="funnel-seg-wrap" key={stage.stage_id}>
-            {i > 0 ? <ChevronRight size={14} className="funnel-arrow" /> : null}
-            <button type="button" className={`funnel-seg ${stage.status}`} onClick={() => onSelect(funnelTarget(stage.stage_id))}>
-              <span className="funnel-label">
-                <span className={`funnel-dot ${stage.status}`} />
-                {stage.label}
-              </span>
-              <strong className="funnel-count">{count !== null ? count : "—"}</strong>
-            </button>
-          </span>
-        );
-      })}
-    </div>
-  );
-}
-
-// 第二层:5 面板导航(前端常量驱动)。发现旅程卡副行挂 Query/内容/判定 三计数。
-const PANELS: Array<{ id: string; label: string }> = [
-  { id: "walk", label: "发现旅程" },
-  { id: "asset", label: "内容资产" },
-  { id: "learning", label: "学习复盘" },
-  { id: "config", label: "策略配置" }
-];
-
-function PanelNav({
-  stages,
-  activeStage,
-  onSelect
-}: {
-  stages: StageConclusion[];
-  activeStage: string;
-  onSelect: (stageId: string) => void;
-}) {
-  const statusOf = (id: string) => stages.find((s) => s.stage_id === id)?.status || "";
-  return (
-    <nav className="panel-nav" aria-label="工作台面板">
-      {PANELS.map((p) => {
-        const active = activeStage === p.id;
-        return (
-          <button
-            type="button"
-            key={p.id}
-            className={`panel-nav-tab ${statusOf(p.id)} ${active ? "active" : ""}`}
-            onClick={() => onSelect(p.id)}
-          >
-            {p.label}
-          </button>
-        );
-      })}
-    </nav>
-  );
-}
-
-function stageCount(dashboard: DashboardResponse, stageId: string): number | null {
-  const counts = dashboard.counts || {};
-  const summary = dashboard.business_summary;
-  if (stageId === "query") return summary.query_count ?? counts.queries ?? 0;
-  if (stageId === "platform") return summary.content_count ?? counts.discovered_content_items ?? 0;
-  if (stageId === "judge") return counts.rule_decisions ?? 0;
-  if (stageId === "walk") return counts.walk_actions ?? 0;
-  if (stageId === "asset") return summary.kept_count ?? summary.asset_count ?? 0;
-  return null;
-}
-
-
-function StagePanel({
-  activeStage,
-  data,
-  runtimeFile,
-  onOpenRuntimeFile,
-  onOpenDrawer
-}: {
-  activeStage: string;
-  data: DashboardData;
-  runtimeFile: RuntimeFileResponse | null;
-  onOpenRuntimeFile: (filename: string) => void;
-  onOpenDrawer: (drawer: DrawerContent) => void;
-}) {
-  if (activeStage === "walk") {
-    return (
-      <BusinessSection title="内容发现旅程" icon={<GitBranch size={17} />}>
-        <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "walk")} />
-        <WalkJourney
-          runId={data.dashboard.run_id}
-          contentItems={data.contentItems}
-          queries={data.queries.items}
-          fallbackEdges={data.dashboard.walk_graph.edges}
-          onOpen={(payload, kind = "walk") =>
-            onOpenDrawer({
-              kind,
-              title: String(payload["这个提示词在做什么"] ? "AI 扩写提示词" : payload.title || payload.id || "视频旅程详情"),
-              payload
-            })
-          }
-        />
-      </BusinessSection>
-    );
-  }
-  if (activeStage === "asset") {
-    return (
-      <BusinessSection title="内容资产 · 成品货架" icon={<Target size={17} />}>
-        <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "asset")} />
-        <ContentAssetShelf
-          contentItems={data.contentItems}
-          kept={compactValue(data.dashboard.business_summary.kept_count)}
-          review={compactValue(data.dashboard.business_summary.review_count)}
-          rejected={compactValue(data.dashboard.business_summary.rejected_count)}
-        />
-      </BusinessSection>
-    );
-  }
-  if (activeStage === "learning") {
-    return (
-      <BusinessSection title="策略学习结论" icon={<Target size={17} />}>
-        <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "learning")} />
-        <div className="business-card-list">
-          <div className="business-record-card">
-            <strong>{data.dashboard.strategy_review_status}</strong>
-            <span>策略复盘只展示业务建议;原始 review 可在技术详情查看。</span>
-          </div>
-        </div>
-      </BusinessSection>
-    );
-  }
-  if (activeStage === "config") {
-    return (
-      <BusinessSection title="策略配置" icon={<SlidersHorizontal size={17} />}>
-        <StrategyConfigPanel />
-      </BusinessSection>
-    );
-  }
-  return (
-    <BusinessSection title={runtimeFile ? runtimeFile.filename : "技术详情"} icon={<FileJson size={17} />}>
-      {runtimeFile ? (
-        <pre className="code-block">
-          {JSON.stringify(runtimeFile.records || runtimeFile.data || {}, null, 2)}
-        </pre>
-      ) : (
-        <div className="empty-state">请选择 runtime 文件</div>
-      )}
-    </BusinessSection>
-  );
-}
-
-function BusinessSection({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) {
-  return (
-    <section className="business-section">
-      <div className="section-title">
-        <strong>{icon}{title}</strong>
-      </div>
-      {children}
-    </section>
-  );
-}
-
-function ConclusionBody({ stage }: { stage?: StageConclusion }) {
-  if (!stage) {
-    return null;
-  }
-  return (
-    <div className="conclusion-body">
-      <StatusBadge status={stage.status} />
-      <strong>{stage.headline}</strong>
-      <span>{stage.detail}</span>
-    </div>
-  );
-}
-
-function rulePackLabel(rulePack: unknown): string {
-  const value = String(rulePack || "");
-  const labels: Record<string, string> = {
-    douyin_content_discovery_rule_pack_v1: "内容发现规则包 V1(抖音)",
-    douyin_path_stop_rule_pack_v1: "路径停止规则包 V1(future)",
-    douyin_budget_observe_rule_pack_v1: "预算观察规则包 V1(future)",
-    douyin_author_expand_rule_pack_v1: "作者扩展规则包 V1(future)",
-    douyin_tag_expansion_rule_pack_v1: "tag 扩散规则包 V1(future)",
-    Content_Rule_Pack_V1: "内容发现规则包 V1",
-    "Content Rule Pack V1": "内容发现规则包 V1"
-  };
-  // fallback 显示原始 id,不再兜底成误导性的默认中文名。
-  return labels[value] || (value ? value : "未记录");
-}
-
-function hardGateLabel(item: RuleApplicationSummary): string {
-  return item.hard_gate_status === "命中" || item.hard_gate_status === "没通过" ? "没通过" : "通过";
-}
-
-function scoreLabel(score: unknown): string {
-  if (score === null || score === undefined || score === "") {
-    return "未打分";
-  }
-  return `${compactValue(score)} / 100`;
-}
-
-function decisionActionLabel(action: unknown): string {
-  const value = String(action || "");
-  const labels: Record<string, string> = {
-    ADD_TO_CONTENT_POOL: "入池",
-    KEEP_CONTENT_FOR_REVIEW: "待复看",
-    REJECT_CONTENT: "淘汰"
-  };
-  return labels[value] || compactValue(action);
-}
-
-function effectStatusLabel(status: unknown): string {
-  const value = String(status || "");
-  const labels: Record<string, string> = {
-    success: "业务效果:成功",
-    pending: "业务效果:待复看",
-    failed: "业务效果:失败",
-    rule_blocked: "业务效果:规则阻断"
-  };
-  return labels[value] || "业务效果未记录";
-}
-
-function reasonLabel(reason: unknown): string {
-  const value = String(reason || "");
-  const labels: Record<string, string> = {
-    content_pattern_recall_required: "Pattern 回扣未通过:内容没有证明能回到本次需求 Pattern",
-    missing_content_portrait: "内容画像缺失:缺少点赞画像或 50+ 判断所需数据",
-    missing_source_evidence: "来源证据缺失:无法追溯这条内容来自哪个 query / path",
-    missing_platform_content_id: "内容身份缺失:缺少平台视频 ID",
-    missing_score: "分数缺失:无法完成分数阈值判断",
-    high_risk_content: "安全风险高:内容命中高风险判断",
-    // V3 Gemini 判定原因码
-    content_not_fit_senior: "Gemini 判定:内容不适合 50+ 人群",
-    content_low_confidence: "Gemini 判定置信度过低(< 0.6)",
-    content_judge_failed: "Gemini 判定技术失败:进入待复看",
-    content_score_reject: "综合分未达阈值:淘汰",
-    age_50_plus_weak: "50+ 适配偏弱",
-    category_or_element_binding_required: "类目 / 要素绑定未命中"
-  };
-  return labels[value] || compactValue(reason);
-}
-
-
-function TechnicalDetailsDrawer({
-  drawer,
-  onClose
-}: {
-  drawer: DrawerContent;
-  onClose: () => void;
-}) {
-  if (!drawer) {
-    return null;
-  }
-  const tabs =
-    drawer.kind === "rule"
-      ? ["业务解释", "规则回放", "原始记录"]
-      : drawer.kind === "walk"
-        ? ["业务解释", "边 / 节点", "原始记录"]
-        : ["业务解释", "DB / runtime 对齐", "validation"];
-  return (
-    <div className="drawer-backdrop" onClick={onClose}>
-      <aside className="technical-drawer" onClick={(event) => event.stopPropagation()}>
-        <div className="drawer-header">
-          <div>
-            <span>技术详情</span>
-            <strong>{drawer.title}</strong>
-          </div>
-          <button className="icon-button" onClick={onClose} type="button">×</button>
-        </div>
-        <div className="drawer-tabs">
-          {tabs.map((tab) => <span className="badge" key={tab}>{tab}</span>)}
-        </div>
-        {drawer.kind === "rule" ? (
-          <RuleDrawerContent payload={drawer.payload as RuleApplicationSummary} />
-        ) : (
-          <pre className="code-block">{JSON.stringify(drawer.payload, null, 2)}</pre>
-        )}
-      </aside>
-    </div>
-  );
-}
-
-function RuleDrawerContent({ payload }: { payload: RuleApplicationSummary }) {
-  return (
-    <div className="drawer-business-content">
-      <div className="drawer-explain-card">
-        <span>这条规则在判断什么</span>
-        <strong>{rulePackLabel(payload.rule_pack)}</strong>
-        <p>
-          这套规则包负责判断一条抖音视频是否值得进入内容池。它会先看硬性筛选门槛,
-          再看内容画像、互动表现、新鲜度等分数。
-        </p>
-      </div>
-      <div className="drawer-explain-grid">
-        <div>
-          <span>判断对象</span>
-          <strong>抖音视频 ID</strong>
-          <p>{compactValue(payload.platform_content_id)}</p>
-        </div>
-        <div>
-          <span>规则包编号</span>
-          <strong>{compactValue(payload.rule_pack)}</strong>
-          <p>用于复盘这次 run 到底用了哪一版规则。</p>
-        </div>
-        <div>
-          <span>硬性筛选门槛</span>
-          <strong>{hardGateLabel(payload)}</strong>
-          <p>{reasonLabel(payload.decision_reason_code)}</p>
-        </div>
-        <div>
-          <span>最终判断</span>
-          <strong>{decisionActionLabel(payload.decision_action)}</strong>
-          <p>{effectStatusLabel(payload.content_effect_status)}</p>
-        </div>
-      </div>
-      <details className="raw-details">
-        <summary>查看原始规则回放记录</summary>
-        <pre className="code-block">{JSON.stringify(payload, null, 2)}</pre>
-      </details>
-    </div>
-  );
-}
-

+ 0 - 176
web/features/runs/RunListPage.tsx

@@ -1,176 +0,0 @@
-"use client";
-
-import { Search } from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { AppShell } from "@/components/layout/AppShell";
-import { DataOriginBadge, StatusBadge } from "@/components/badges/StatusBadge";
-import { listRuns } from "@/lib/api/client";
-import { platformLabel } from "@/lib/platform/content";
-import { statusLabel } from "@/lib/status/status";
-import { DemandDetail } from "@/features/runs/DemandDetail";
-import type { RunListItem, RunListResponse } from "@/lib/api/types";
-
-// run 级状态仅此 4 种(RunState.status 合同);pending/rule_blocked 是内容/决策级状态,不在此列。
-const statusOptions = ["", "success", "partial_success", "failed", "running"];
-
-export function RunListPage() {
-  const [status, setStatus] = useState("");
-  const [search, setSearch] = useState("");
-  const [data, setData] = useState<RunListResponse | null>(null);
-  const [error, setError] = useState<string | null>(null);
-  const [loading, setLoading] = useState(true);
-  const [selectedId, setSelectedId] = useState<string | null>(null);
-
-  const load = useCallback(async () => {
-    setLoading(true);
-    setError(null);
-    const params = new URLSearchParams({ page_size: "50" });
-    if (status) {
-      params.set("status", status);
-    }
-    try {
-      setData(await listRuns(params));
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err));
-    } finally {
-      setLoading(false);
-    }
-  }, [status]);
-
-  useEffect(() => {
-    void load();
-  }, [load]);
-
-  const items = useMemo(() => {
-    let source = data?.items || [];
-    const keyword = search.trim().toLowerCase();
-    if (keyword) {
-      source = source.filter((item) =>
-        [item.run_id, item.policy_run_id, item.error_code, item.platform_mode]
-          .filter(Boolean)
-          .some((value) => String(value).toLowerCase().includes(keyword))
-      );
-    }
-    // 按运行时间倒序(最新在最上);无时间的(如冒烟测试)沉到最底。
-    return [...source].sort((a, b) => {
-      const ta = a.started_at ? Date.parse(a.started_at) : NaN;
-      const tb = b.started_at ? Date.parse(b.started_at) : NaN;
-      const va = Number.isNaN(ta) ? -Infinity : ta;
-      const vb = Number.isNaN(tb) ? -Infinity : tb;
-      return vb - va;
-    });
-  }, [data, search]);
-
-  // 默认选中第一条(最新);当前选中项被筛掉时回退到第一条。
-  useEffect(() => {
-    if (!items.length) {
-      setSelectedId(null);
-      return;
-    }
-    if (!selectedId || !items.some((it) => it.run_id === selectedId)) {
-      setSelectedId(items[0].run_id);
-    }
-  }, [items, selectedId]);
-
-  const selected = items.find((it) => it.run_id === selectedId) || null;
-
-  return (
-    <AppShell onRefresh={load}>
-      <section className="filter-bar">
-        <div className="filter-title">
-          <span>Run 列表</span>
-          <strong>真实运行记录</strong>
-        </div>
-        <label className="field">
-          <span>状态</span>
-          <select className="select" value={status} onChange={(event) => setStatus(event.target.value)}>
-            {statusOptions.map((option) => (
-              <option key={option || "all"} value={option}>
-                {option || "全部"}
-              </option>
-            ))}
-          </select>
-        </label>
-        <label className="field">
-          <span>搜索</span>
-          <Search size={15} />
-          <input
-            className="input"
-            value={search}
-            onChange={(event) => setSearch(event.target.value)}
-            placeholder="run_id / error_code"
-          />
-        </label>
-        {data ? <DataOriginBadge origin={data.data_origin} /> : null}
-      </section>
-
-      <section className="main-grid">
-        <div className="list-panel">
-          {loading ? <div className="loading-state">加载中</div> : null}
-          {error ? <div className="error-state">{error}</div> : null}
-          {!loading && !error && !items.length ? <div className="empty-state">没有匹配的 run</div> : null}
-          {!loading && !error
-            ? items.map((item) => (
-                <RunCard
-                  item={item}
-                  active={item.run_id === selectedId}
-                  onSelect={() => setSelectedId(item.run_id)}
-                  key={item.run_id}
-                />
-              ))
-            : null}
-        </div>
-        <div className="detail-panel">
-          {selected ? (
-            <DemandDetail item={selected} key={selected.run_id} />
-          ) : (
-            <div className="empty-state">左侧选择一条需求查看详情。</div>
-          )}
-        </div>
-      </section>
-    </AppShell>
-  );
-}
-
-// 内容形态文案;接口给英文枚举(video/image/...),列表统一显示中文。
-function contentFormatLabel(value: unknown): string {
-  const map: Record<string, string> = { video: "视频", image: "图文", note: "图文", live: "直播" };
-  const key = String(value || "").toLowerCase();
-  return map[key] || (key ? key : "");
-}
-
-// ISO 时间 → 「YYYY-MM-DD HH:mm」(本地时区);拿不到则空。
-function fmtRunTime(iso?: string | null): string {
-  if (!iso) return "";
-  const d = new Date(iso);
-  if (Number.isNaN(d.getTime())) return "";
-  const p = (n: number) => String(n).padStart(2, "0");
-  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
-}
-
-function RunCard({ item, active, onSelect }: { item: RunListItem; active: boolean; onSelect: () => void }) {
-  // 数据源行:平台 · 内容形态 · 策略(用户选「显示数据源平台+格式」取代未落库的服务器字段)。
-  const source = [
-    item.platform ? platformLabel(item.platform) : "",
-    contentFormatLabel(item.content_format),
-    item.strategy_version ? `策略 ${item.strategy_version}` : ""
-  ].filter(Boolean);
-  const runTime = fmtRunTime(item.started_at);
-  return (
-    <button type="button" className={`run-card ${active ? "active" : ""}`} onClick={onSelect}>
-      <div className="run-card-title">
-        <span className="run-card-demand">{item.demand_name || item.run_id}</span>
-        <StatusBadge status={item.status} />
-      </div>
-      <div className="run-card-meta">
-        <span className="run-card-time">🕒 {runTime || "运行时间未知"}</span>
-        {source.length ? <span>{source.join(" · ")}</span> : null}
-      </div>
-      <div className="run-card-meta">
-        <span className="run-card-id">{item.run_id}</span>
-        {item.validation_status ? <span>校验{statusLabel(item.validation_status)}</span> : null}
-        {item.error_code ? <span className="run-card-err">{item.error_code}</span> : null}
-      </div>
-    </button>
-  );
-}

+ 0 - 234
web/features/runs/RunTimelinePage.tsx

@@ -1,234 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useMemo, useState } from "react";
-import Link from "next/link";
-import { getTimeline } from "@/lib/api/client";
-import type { TimelineItem, TimelineResponse } from "@/lib/api/types";
-import { AppShell } from "@/components/layout/AppShell";
-import { MetricCard } from "@/components/cards/MetricCard";
-import { StatusBadge } from "@/components/badges/StatusBadge";
-import { statusLabel } from "@/lib/status/status";
-
-// graph.py 节点注册顺序(M6 stage 名 = 节点名)。
-const STAGE_ORDER = [
-  "load_source",
-  "plan_queries",
-  "search_platform",
-  "build_discovered_content",
-  "recall_pattern",
-  "load_policy",
-  "evaluate_rules",
-  "execute_walk",
-  "plan_walk",
-  "record_run",
-  "commit_results",
-  "review_strategy"
-];
-
-const SOURCE_FILTERS: Array<{ id: string; label: string }> = [
-  { id: "all", label: "全部" },
-  { id: "stage", label: "阶段事件" },
-  { id: "judge", label: "判定事件" },
-  { id: "walk", label: "游走动作" },
-  { id: "path", label: "路径记录" },
-  { id: "other", label: "其他" }
-];
-
-function itemFilterKind(item: TimelineItem): string {
-  const eventType = String(item.event_type || "");
-  if (eventType.startsWith("stage_")) return "stage";
-  // V3:Gemini 判定相关事件(配额截断、pattern recall);兼容老 run 的 decode_* 也归此类。
-  if (eventType === "gemini_quota_exhausted" || eventType.startsWith("decode_") || item.source === "pattern_recall_evidence.jsonl") {
-    return "judge";
-  }
-  if (item.source === "walk_actions.jsonl") return "walk";
-  if (item.source === "source_path_records.jsonl") return "path";
-  return "other";
-}
-
-function rawPayload(item: TimelineItem): Record<string, unknown> {
-  const record = item.record || {};
-  const payload = record.raw_payload;
-  return payload && typeof payload === "object" ? (payload as Record<string, unknown>) : record;
-}
-
-function countsLine(counts: Record<string, number>): string {
-  const entries = Object.entries(counts || {});
-  if (!entries.length) return "0";
-  return entries.map(([key, value]) => `${statusLabel(key)} ${value}`).join(" / ");
-}
-
-// Gemini 配额截断:run_events 里的 gemini_quota_exhausted 事件,显示 used/cap。
-function geminiQuotaLabel(items: TimelineItem[]): string {
-  const ev = items.find((it) => it.event_type === "gemini_quota_exhausted");
-  if (!ev) return "未触发";
-  const payload = rawPayload(ev);
-  const used = payload.used ?? "?";
-  const cap = payload.cap ?? "?";
-  return `已截断 ${used}/${cap}`;
-}
-
-export function RunTimelinePage({ runId }: { runId: string }) {
-  const [data, setData] = useState<TimelineResponse | null>(null);
-  const [error, setError] = useState<string | null>(null);
-  const [loading, setLoading] = useState(true);
-  const [filter, setFilter] = useState("all");
-
-  const load = useCallback(async () => {
-    setLoading(true);
-    setError(null);
-    try {
-      setData(await getTimeline(runId));
-    } catch (err) {
-      setError(err instanceof Error ? err.message : "时间线加载失败");
-    } finally {
-      setLoading(false);
-    }
-  }, [runId]);
-
-  useEffect(() => {
-    void load();
-  }, [load]);
-
-  const summary = data?.summary;
-  const maxStageMs = useMemo(() => {
-    const values = Object.values(summary?.stage_duration_ms || {});
-    return values.length ? Math.max(...values, 1) : 1;
-  }, [summary]);
-  const failedStages = useMemo(() => {
-    const stages = new Map<string, TimelineItem>();
-    for (const item of data?.items || []) {
-      if (item.event_type === "stage_failed") {
-        const stage = String(rawPayload(item).stage || "");
-        if (stage) stages.set(stage, item);
-      }
-    }
-    return stages;
-  }, [data]);
-  const visibleItems = useMemo(
-    () => (data?.items || []).filter((item) => filter === "all" || itemFilterKind(item) === filter),
-    [data, filter]
-  );
-
-  return (
-    <AppShell onRefresh={load} showBack>
-      <section className="detail-panel">
-        <header className="detail-header">
-          <div>
-            <h1 className="detail-title">运行时间线 · {runId}</h1>
-            <p className="muted">
-              可观测事实:阶段耗时、失败、限流与 Gemini 判定 / 配额。仅呈现事实,不做“卡住”判断。
-            </p>
-          </div>
-          <Link className="muted" href={`/runs/${encodeURIComponent(runId)}`}>
-            返回运行详情
-          </Link>
-        </header>
-
-        {loading ? <div className="loading-state">时间线加载中…</div> : null}
-        {error ? <div className="error-state">{error}</div> : null}
-
-        {summary ? (
-          <>
-            <section className="section">
-              <h2 className="section-title">运行摘要</h2>
-              <div className="metrics-grid">
-                <MetricCard
-                  label="总耗时"
-                  value={summary.total_duration_ms == null ? "未知" : `${summary.total_duration_ms} ms`}
-                />
-                <MetricCard label="query 失败" value={summary.query_failure_count} />
-                <MetricCard label="平台限流" value={summary.platform_rate_limited_count} />
-                <MetricCard
-                  label="Gemini 配额"
-                  value={geminiQuotaLabel(data?.items || [])}
-                />
-                {summary.decode_status_counts && Object.keys(summary.decode_status_counts).length ? (
-                  <MetricCard
-                    label="decode 状态(历史)"
-                    value={countsLine(summary.decode_status_counts)}
-                  />
-                ) : null}
-                <MetricCard label="错误分布" value={countsLine(summary.error_counts)} />
-                <MetricCard label="游走状态" value={countsLine(summary.walk_status_counts)} />
-              </div>
-            </section>
-
-            <section className="section">
-              <h2 className="section-title">阶段耗时</h2>
-              <div className="stage-bars">
-                {STAGE_ORDER.map((stage) => {
-                  const duration = summary.stage_duration_ms?.[stage];
-                  const failedItem = failedStages.get(stage);
-                  if (duration == null && !failedItem) return null;
-                  const width = Math.max(((duration || 0) / maxStageMs) * 100, 2);
-                  return (
-                    <div className={`stage-bar-row${failedItem ? " failed" : ""}`} key={stage}>
-                      <span className="stage-bar-label">{stage}</span>
-                      <span className="stage-bar-track">
-                        <span className="stage-bar-fill" style={{ width: `${width}%` }} />
-                      </span>
-                      <span className="stage-bar-value">
-                        {duration == null ? "—" : `${duration} ms`}
-                        {failedItem
-                          ? ` · 失败(${String(failedItem.error_code || rawPayload(failedItem).message || "未知原因")})`
-                          : ""}
-                      </span>
-                    </div>
-                  );
-                })}
-              </div>
-            </section>
-          </>
-        ) : null}
-
-        <section className="section">
-          <h2 className="section-title">
-            事件流(日志线) <span className="muted">{visibleItems.length} / {data?.total ?? 0} 条</span>
-          </h2>
-          <div className="filter-bar">
-            {SOURCE_FILTERS.map((option) => (
-              <button
-                key={option.id}
-                type="button"
-                className={`chip${filter === option.id ? " active" : ""}`}
-                onClick={() => setFilter(option.id)}
-              >
-                {option.label}
-              </button>
-            ))}
-          </div>
-          {visibleItems.length === 0 && !loading ? (
-            <div className="empty-state">当前过滤条件下没有事件。</div>
-          ) : null}
-          <ol className="event-list">
-            {visibleItems.map((item, index) => {
-              const payload = rawPayload(item);
-              const eventId = String((item.record || {}).event_id || `${item.source}-${index}`);
-              return (
-                <li className={`event-row${item.event_type === "stage_failed" ? " failed" : ""}`} key={`${eventId}-${index}`}>
-                  <div className="event-row-head">
-                    <span className="muted">{String(item.timestamp || "").replace("T", " ").slice(0, 23)}</span>
-                    <span className="event-type">{statusLabel(String(item.event_type || item.stage || ""))}</span>
-                    <StatusBadge status={item.status} />
-                    {item.error_code ? <span className="error-state inline">{String(item.error_code)}</span> : null}
-                    <span className="muted">{eventId}</span>
-                  </div>
-                  <details>
-                    <summary className="muted">原始记录(日志明细)</summary>
-                    <pre className="event-payload">{JSON.stringify(payload, null, 2)}</pre>
-                  </details>
-                </li>
-              );
-            })}
-          </ol>
-          <p className="muted">
-            兜底:完整 run_events.jsonl 可在
-            <Link href={`/runs/${encodeURIComponent(runId)}`}> 运行详情</Link>
-            的「运行文件」区分页查看。
-          </p>
-        </section>
-      </section>
-    </AppShell>
-  );
-}

+ 0 - 381
web/features/runs/StrategyConfigPanel.tsx

@@ -1,381 +0,0 @@
-"use client";
-
-// 策略配置面板(run 工作台第 5 面板):面向运营的业务化视图,三个干净模块——
-// ① 搜索 Prompt(只看发给大模型的原文)② 判定规则(红线/打分/入池线)③ 游走规则(扩展动作+总量去重)。
-// 原始字段表留在 /config 工程页,本面板不再堆变量名/术语。只读、配置为全局当前值(非该 run 快照)。
-import { useEffect, useState, type ReactNode } from "react";
-import Link from "next/link";
-import { Compass, FileText, Pencil, ShieldCheck, Table2 } from "lucide-react";
-import { getConfigQueryPrompts, getConfigRulePacks, getConfigWalkPolicy } from "@/lib/api/client";
-import type { ConfigFileResponse } from "@/lib/api/types";
-
-type AnyRec = Record<string, unknown>;
-
-function rec(v: unknown): AnyRec {
-  return v && typeof v === "object" ? (v as AnyRec) : {};
-}
-function rows(v: unknown): AnyRec[] {
-  return Array.isArray(v) ? (v as AnyRec[]) : [];
-}
-// walk_policy 值可能是裸值,也可能 {value,...} 包裹
-function uw(v: unknown): unknown {
-  return v && typeof v === "object" && "value" in (v as AnyRec) ? (v as AnyRec).value : v;
-}
-function n(v: unknown): number | null {
-  const x = Number(uw(v));
-  return Number.isFinite(x) ? x : null;
-}
-
-// 判定动作 → 业务文案 + 色板
-const ACTION: Record<string, { cn: string; cls: string }> = {
-  ADD_TO_CONTENT_POOL: { cn: "入池", cls: "pool" },
-  KEEP_CONTENT_FOR_REVIEW: { cn: "转待复看", cls: "review" },
-  REJECT_CONTENT: { cn: "淘汰", cls: "reject" },
-  DO_NOT_EXPAND_AUTHOR: { cn: "不进作者主页", cls: "muted" }
-};
-// 数据完整性类红线的技术 label 软化成人话
-const GATE_RELABEL: Record<string, string> = {
-  missing_platform_content_id: "内容缺少 ID(抓取不完整)",
-  missing_source_evidence: "缺少需求证据(无法溯源)",
-  missing_platform_author_id: "缺少作者 ID(无法进主页)"
-};
-
-function ConfigBlock({
-  icon,
-  title,
-  subtitle,
-  editHint,
-  loading,
-  error,
-  children
-}: {
-  icon: ReactNode;
-  title: string;
-  subtitle: string;
-  editHint: string;
-  loading: boolean;
-  error: boolean;
-  children: ReactNode;
-}) {
-  return (
-    <section className="cfg-block">
-      <div className="cfg-block-head">
-        <div className="cfg-block-titles">
-          <strong>{icon} {title}</strong>
-          <span className="cfg-block-sub">{subtitle}</span>
-        </div>
-        <span className="cfg-edit-hint"><Pencil size={12} /> 改这里:{editHint}</span>
-      </div>
-      {loading ? (
-        <div className="loading-state">加载中…</div>
-      ) : error ? (
-        <div className="error-state">这块配置加载失败(其余不受影响)。</div>
-      ) : (
-        children
-      )}
-    </section>
-  );
-}
-
-// ===== ① 搜索 Prompt:只看原文 =====
-function PromptModule({ data }: { data: AnyRec }) {
-  const profiles = rec(data.profiles);
-  const entries = Object.entries(profiles);
-  if (!entries.length) return <div className="empty-state">暂无 Prompt 配置。</div>;
-  return (
-    <div className="cfg-prompt">
-      {entries.map(([name, raw]) => {
-        const p = rec(raw);
-        return (
-          <div className="cfg-prompt-profile" key={name}>
-            {entries.length > 1 ? <div className="cfg-prompt-name">{name}</div> : null}
-            <div className="cfg-prompt-block">
-              <span className="cfg-prompt-tag">系统提示词 · System</span>
-              <pre className="cfg-prompt-text">{String(p.system || "—")}</pre>
-            </div>
-            <div className="cfg-prompt-block">
-              <span className="cfg-prompt-tag">用户提示词 · User</span>
-              <pre className="cfg-prompt-text">{String(p.user || "—")}</pre>
-            </div>
-          </div>
-        );
-      })}
-      <p className="cfg-hint-line">
-        发给大模型的就是上面这两段原文。<code>{"{seed_term}"}</code>、<code>{"{evidence_context}"}</code> 等占位符会在运行时被真实内容替换。
-      </p>
-    </div>
-  );
-}
-
-// ===== ② 判定规则:红线 / 打分 / 入池线 =====
-function JudgeModule({ pack }: { pack: AnyRec | null }) {
-  if (!pack) return <div className="empty-state">暂无判定规则。</div>;
-  const gates = rows(pack.hard_gates);
-  const dims = rows(rec(pack.scorecard).dimensions).filter((d) => String(d.runtime_status || "active") === "active");
-  const thresholds = rows(pack.thresholds);
-  const totalMax = dims.reduce((s, d) => s + (n(d.max_score) || 0), 0) || 100;
-
-  return (
-    <div className="cfg-judge">
-      {/* 红线 */}
-      <div className="cfg-sub">
-        <div className="cfg-sub-title">红线 · 命中即处理(不进入打分)</div>
-        <div className="cfg-gate-list">
-          {gates.map((g, i) => {
-            const act = ACTION[String(g.decision_action || "")] || { cn: String(g.decision_action || ""), cls: "muted" };
-            const label = GATE_RELABEL[String(g.gate_id || "")] || String(g.label || g.gate_id || "");
-            return (
-              <div className="cfg-gate-row" key={i}>
-                <span className="cfg-gate-label">{label}</span>
-                <span className={`cfg-act ${act.cls}`}>{act.cn}</span>
-              </div>
-            );
-          })}
-        </div>
-      </div>
-
-      {/* 打分 */}
-      <div className="cfg-sub">
-        <div className="cfg-sub-title">怎么打分 · 满分 {totalMax}</div>
-        <div className="cfg-score-bars">
-          {dims.map((d, i) => {
-            const max = n(d.max_score) || 0;
-            return (
-              <div className="cfg-score-item" key={i}>
-                <div className="cfg-score-top">
-                  <span>{String(d.label || d.key)}</span>
-                  <strong>{max} 分</strong>
-                </div>
-                <div className="cfg-score-bar">
-                  <span style={{ width: `${(max / totalMax) * 100}%` }} />
-                </div>
-              </div>
-            );
-          })}
-        </div>
-      </div>
-
-      {/* 入池线 */}
-      <div className="cfg-sub">
-        <div className="cfg-sub-title">入池线 · 按总分落档</div>
-        <div className="cfg-band-row">
-          {thresholds.map((t, i) => {
-            const act = ACTION[String(t.decision_action || "")] || { cn: String(t.decision_action || ""), cls: "muted" };
-            const min = n(t.min_score);
-            const max = n(t.max_score);
-            const range = min != null && max != null ? `${min}–${max} 分` : "—";
-            return (
-              <div className={`cfg-band ${act.cls}`} key={i}>
-                <span className="cfg-band-range">{range}</span>
-                <strong className="cfg-band-act">{act.cn}</strong>
-              </div>
-            );
-          })}
-        </div>
-      </div>
-    </div>
-  );
-}
-
-// ===== ③ 游走规则:起点判断 → 三种动作 → 拆标签后接着走 + 刹车 =====
-type WalkAction = { title: string; desc: string; trigger: string; limits: string[] };
-
-// edge_permissions 的放行值 → 业务文案
-const PERM: Record<string, { cn: string; cls: string }> = {
-  allow: { cn: "放行", cls: "pool" },
-  allow_low_budget: { cn: "低预算放行", cls: "review" },
-  deny: { cn: "不放行", cls: "reject" }
-};
-// 起点判断只看两种"发现型"游走(收入内容池属判定结果,不在此列)
-const WALK_EDGES: Array<{ edge: string; label: string }> = [
-  { edge: "author_to_works", label: "进作者主页" },
-  { edge: "video_to_hashtag", label: "拆标签再搜" }
-];
-const VERDICTS: Array<{ key: string; cn: string; cls: string }> = [
-  { key: "ADD_TO_CONTENT_POOL", cn: "入池", cls: "pool" },
-  { key: "KEEP_CONTENT_FOR_REVIEW", cn: "待复看", cls: "review" },
-  { key: "REJECT_CONTENT", cn: "淘汰", cls: "reject" }
-];
-
-function WalkModule({ data }: { data: AnyRec }) {
-  const g = rec(data.global);
-  const perm = rec(data.edge_permissions);
-  const budgetById: Record<string, AnyRec> = {};
-  rows(data.edge_budgets).forEach((b) => {
-    budgetById[String(b.edge_id || "")] = b;
-  });
-
-  const limit = (edge: string, key: string, unit: string): string | null => {
-    const v = n(budgetById[edge]?.[key]);
-    return v == null ? null : `${unit} ${v}`;
-  };
-
-  const actions: WalkAction[] = [
-    {
-      title: "翻页继续搜",
-      desc: "同一个搜索词,翻到下一页拿更多结果。",
-      trigger: "任何搜索都可翻页(与判定结果无关)",
-      limits: [limit("query_next_page", "max_per_query", "每个搜索最多翻"), limit("query_next_page", "max_total_actions", "整轮最多")].filter(Boolean) as string[]
-    },
-    {
-      title: "进作者主页取作品",
-      desc: "顺着这条内容的作者主页,再拿几条同作者作品。",
-      trigger: "入池→正常进 · 待复看→只低预算探 · 淘汰→不进",
-      limits: [limit("author_to_works", "max_works_per_author", "每个作者最多取"), limit("author_to_works", "max_total_actions", "整轮最多")].filter(Boolean) as string[]
-    },
-    {
-      title: "从内容拆标签再搜",
-      desc: "把内容里的标签拆出来,作为新搜索词继续找。",
-      trigger: "仅「入池」内容、且标签强相关才会拆",
-      limits: [limit("hashtag_to_query", "max_total_actions", "整轮最多")].filter(Boolean) as string[]
-    }
-  ];
-
-  const totalActions = n(g.max_total_actions_per_run);
-  const depth = n(g.max_depth);
-  const reseedRounds = n(g.max_reseed_rounds);
-  const geminiCap = n(g.gemini_calls_per_run_cap);
-
-  return (
-    <div className="cfg-walk">
-      {/* A. 起点:用判定结果决定能不能继续游走 */}
-      <div className="cfg-sub">
-        <div className="cfg-sub-title">从哪种内容开始游走 · 由判定结果决定</div>
-        <p className="cfg-walk-note">
-          「好不好」不在这里另判,而是直接用上面②的判定结果:<strong>入池</strong>最优、放开游走;<strong>待复看</strong>只小范围探一探;<strong>淘汰</strong>到此为止。
-        </p>
-        <div className="cfg-perm-table">
-          <div className="cfg-perm-head">
-            <span>判定结果</span>
-            {WALK_EDGES.map((e) => (
-              <span key={e.edge}>{e.label}</span>
-            ))}
-          </div>
-          {VERDICTS.map((v) => {
-            const row = rec(perm[v.key]);
-            return (
-              <div className="cfg-perm-row" key={v.key}>
-                <span className={`cfg-act ${v.cls}`}>{v.cn}</span>
-                {WALK_EDGES.map((e) => {
-                  const p = PERM[String(uw(row[e.edge]) || "deny")] || PERM.deny;
-                  return (
-                    <span className={`cfg-perm-cell ${p.cls}`} key={e.edge}>
-                      {p.cn}
-                    </span>
-                  );
-                })}
-              </div>
-            );
-          })}
-        </div>
-      </div>
-
-      {/* B. 三种游走动作 */}
-      <div className="cfg-sub">
-        <div className="cfg-sub-title">能往哪三个方向走</div>
-        <div className="cfg-walk-actions">
-          {actions.map((a) => (
-            <div className="cfg-walk-card" key={a.title}>
-              <div className="cfg-walk-card-head">
-                <strong>{a.title}</strong>
-              </div>
-              <p className="cfg-walk-desc">{a.desc}</p>
-              <div className="cfg-walk-trigger">触发:{a.trigger}</div>
-              <div className="cfg-walk-limits">
-                {a.limits.length ? a.limits.map((l, i) => <span key={i}>{l}</span>) : <span className="muted">无额外上限</span>}
-              </div>
-            </div>
-          ))}
-        </div>
-      </div>
-
-      {/* C. 拆标签之后接着往哪走 + 刹车 */}
-      <div className="cfg-sub">
-        <div className="cfg-sub-title">拆标签之后,怎么接着走</div>
-        <div className="cfg-walk-chain">
-          <span className="cfg-chain-step">入池内容拆出强相关标签</span>
-          <span className="cfg-chain-arrow">→</span>
-          <span className="cfg-chain-step">标签变成新的搜索词</span>
-          <span className="cfg-chain-arrow">→</span>
-          <span className="cfg-chain-step">这条新搜索重新走全流程:翻页 → 判定 → 对又判成入池的,再进作者主页 / 再拆标签</span>
-          <span className="cfg-chain-arrow">↻</span>
-          <span className="cfg-chain-step">如此层层深入,直到撞上下面的刹车</span>
-        </div>
-        <div className="cfg-walk-brakes">
-          {depth != null ? <span>最多深入 <b>{depth}</b> 层</span> : null}
-          {reseedRounds != null ? <span>拆标签再搜最多 <b>{reseedRounds}</b> 轮</span> : null}
-          {totalActions != null ? <span>整轮最多 <b>{totalActions}</b> 个动作</span> : null}
-          {geminiCap != null ? <span>大模型判定上限 <b>{geminiCap}</b> 次/轮</span> : null}
-          <span>只有「入池」内容能当种子</span>
-          <span>只有强相关标签才扩散</span>
-          <span>重复内容自动去重</span>
-        </div>
-      </div>
-    </div>
-  );
-}
-
-export function StrategyConfigPanel() {
-  const [prompts, setPrompts] = useState<ConfigFileResponse | null>(null);
-  const [rulePacks, setRulePacks] = useState<ConfigFileResponse | null>(null);
-  const [walkPolicy, setWalkPolicy] = useState<ConfigFileResponse | null>(null);
-  const [err, setErr] = useState<{ p: boolean; r: boolean; w: boolean }>({ p: false, r: false, w: false });
-
-  useEffect(() => {
-    let alive = true;
-    getConfigQueryPrompts().then((r) => alive && setPrompts(r)).catch(() => alive && setErr((e) => ({ ...e, p: true })));
-    getConfigRulePacks().then((r) => alive && setRulePacks(r)).catch(() => alive && setErr((e) => ({ ...e, r: true })));
-    getConfigWalkPolicy().then((r) => alive && setWalkPolicy(r)).catch(() => alive && setErr((e) => ({ ...e, w: true })));
-    return () => {
-      alive = false;
-    };
-  }, []);
-
-  const pack0 = ((rulePacks?.data?.rule_packs as AnyRec[] | undefined) || [])[0] || null;
-
-  return (
-    <div className="strategy-config">
-      <div className="cfg-top">
-        <p className="muted cfg-top-note">
-          下面是<strong>当前生效的全局策略配置</strong>(不是本次 run 的快照),都由人来定、可改;改完下次 run 就按新配置跑。
-        </p>
-        <Link className="text-button" href="/config">
-          <Table2 size={14} /> 查看完整规则表
-        </Link>
-      </div>
-
-      <ConfigBlock
-        icon={<FileText size={15} />}
-        title="搜索 Prompt"
-        subtitle="发给大模型、用来扩写搜索词的原文"
-        editHint="query_prompts.v1.json"
-        loading={!prompts && !err.p}
-        error={err.p}
-      >
-        {prompts ? <PromptModule data={prompts.data} /> : null}
-      </ConfigBlock>
-
-      <ConfigBlock
-        icon={<ShieldCheck size={15} />}
-        title="判定规则"
-        subtitle="一条内容怎么被判成 入池 / 待复看 / 淘汰"
-        editHint="规则包映射配置表.xlsx"
-        loading={!rulePacks && !err.r}
-        error={err.r}
-      >
-        <JudgeModule pack={pack0} />
-      </ConfigBlock>
-
-      <ConfigBlock
-        icon={<Compass size={15} />}
-        title="游走规则"
-        subtitle="找到一条好内容后,机器怎么顺藤摸瓜找更多"
-        editHint="游走策略配置表.xlsx"
-        loading={!walkPolicy && !err.w}
-        error={err.w}
-      >
-        {walkPolicy ? <WalkModule data={walkPolicy.data} /> : null}
-      </ConfigBlock>
-    </div>
-  );
-}

+ 0 - 794
web/features/runs/WalkJourney.tsx

@@ -1,794 +0,0 @@
-"use client";
-
-import { useEffect, useMemo, useRef, useState, type ReactElement } from "react";
-import {
-  ChevronDown,
-  ChevronRight,
-  ShieldCheck,
-  Search,
-  Network,
-  ListTree,
-  Target,
-  PlayCircle,
-  Hash,
-  UserSearch,
-  CornerDownRight,
-  ArrowUpLeft,
-  Database,
-  RotateCw
-} from "lucide-react";
-import { getConfigRulePacks, getRuntimeFile } from "@/lib/api/client";
-import type { ContentItemsResponse, WalkGraphEdge } from "@/lib/api/types";
-import { contentUrl, embedPlayerUrl, platformLabel } from "@/lib/platform/content";
-import { llmVariantPromptPayload } from "@/features/runs/queryPrompt";
-
-type AnyRec = Record<string, unknown>;
-
-// 游走边 → 运营动作中文(地图 / 树 / 下游标注共用)
-const EDGE_ACTION_CN: Record<string, string> = {
-  query_next_page: "翻页找更多",
-  search_page_to_content: "拆出视频",
-  video_to_author: "进作者主页",
-  author_to_works: "进作者主页",
-  author_work_to_content: "作者作品回判定",
-  video_to_hashtag: "拆话题",
-  hashtag_to_query: "话题回搜",
-  decision_to_asset: "收入内容池",
-  path_stop: "停止这条线",
-  budget_downgrade: "额度降档"
-};
-
-// 硬门槛 → 运营白话(配置 label 仍带字段名,这里覆盖成人话;未知 gate 回落到 label)。
-const GATE_CN: Record<string, string> = {
-  missing_platform_content_id: "没有视频 ID",
-  missing_source_evidence: "没有来源出处",
-  high_risk_content: "高风险内容",
-  not_safe_for_50_plus: "明显不适合 50+ 观看",
-  missing_platform_author_id: "没有作者信息",
-  not_fit_senior: "不适合 50 岁以上人群",
-  low_confidence: "Gemini 判定信心不足",
-  judge_failed: "Gemini 判定失败(转待复看)"
-};
-
-const RESULT_META: Record<string, { cn: string; cls: string; outcome: string }> = {
-  ADD_TO_CONTENT_POOL: { cn: "入池", cls: "pass", outcome: "收入内容池,并可去作者主页找更多" },
-  KEEP_CONTENT_FOR_REVIEW: { cn: "待复看", cls: "review", outcome: "暂存待人工复看(作者扩展用减半额度)" },
-  REJECT_CONTENT: { cn: "淘汰", cls: "reject", outcome: "停止这条线,不再从它往下找" }
-};
-
-type VideoJourney = {
-  id: string;
-  title: string;
-  platform: string;
-  author: string;
-  authorId: string;
-  queryId: string;
-  prevStep: string;
-  action: string;
-  fit: unknown;
-  conf: unknown;
-  rel: unknown;
-  judgeStatus: string;
-  reason: string;
-  score: unknown;
-  downstreamCn: string[];
-  page?: number; // 这条视频来自该搜索词的第几页(翻页结构化用)
-  scorecard?: AnyRec; // rule_decision.scorecard,详情里展示评分明细
-  item: AnyRec; // 原始 content item,详情里取播放器/原帖 URL
-};
-
-function buildVideos(items: AnyRec[], actionsByFrom: Map<string, Set<string>>): VideoJourney[] {
-  return items.map((it) => {
-    const pmr = (it.pattern_match_result as AnyRec) || {};
-    const rd = (it.rule_decision as AnyRec) || {};
-    const id = String(it.platform_content_id || "");
-    const action = String(rd.decision_action || pmr.decision_action || "");
-    return {
-      id,
-      title: String(it.title || it.description || `${platformLabel(it.platform)}视频`),
-      platform: String(it.platform || ""),
-      author: String(it.author_display_name || it.platform_author_id || ""),
-      authorId: String(it.platform_author_id || ""),
-      queryId: String(it.search_query_id || ""),
-      prevStep: String(it.previous_discovery_step || ""),
-      action,
-      fit: pmr.fit_senior_50plus,
-      conf: pmr.fit_confidence,
-      rel: pmr.relevance_score,
-      judgeStatus: String(pmr.judge_status || ""),
-      reason: String(pmr.reason || ""),
-      score: rd.score,
-      downstreamCn: [...(actionsByFrom.get(id) || [])],
-      scorecard: (rd.scorecard as AnyRec) || undefined,
-      item: it
-    };
-  });
-}
-
-// 来历面包屑的一段(可点的段带 anchor + 要展开的块)
-type Crumb = { label: string; anchor?: string; blockQid?: string };
-type Lineage = { kind: "seed" | "llm" | "tag"; chip: string; crumbs: Crumb[]; jumpAnchor?: string; jumpBlock?: string };
-type SearchBlock = {
-  qid: string;
-  seq: number;
-  text: string;
-  pageCount: number;
-  lineage: Lineage;
-  rootVideos: VideoJourney[];
-};
-type TreeModel = {
-  blocks: SearchBlock[];
-  authorWorks: Map<string, VideoJourney[]>;
-  authorAttach: Record<string, string>; // authorId -> 触发扩展的视频 id
-  tagFwd: Map<string, { hashtag: string; seq: number; anchor: string; blockQid: string }>; // 源视频 id -> 它催生的 tag 搜索块
-};
-
-export function WalkJourney({
-  runId,
-  contentItems,
-  queries,
-  fallbackEdges,
-  onOpen
-}: {
-  runId: string;
-  contentItems: ContentItemsResponse;
-  queries: AnyRec[];
-  fallbackEdges: WalkGraphEdge[];
-  onOpen: (payload: Record<string, unknown>, kind?: "walk" | "technical") => void;
-}) {
-  const [walkActions, setWalkActions] = useState<AnyRec[] | null>(null);
-  const [searchQueries, setSearchQueries] = useState<AnyRec[] | null>(null);
-  const [rulePack, setRulePack] = useState<AnyRec | null>(null);
-  const [view, setView] = useState<"map" | "tree">("tree");
-  const [openVideo, setOpenVideo] = useState<string | null>(null);
-  const [openBlocks, setOpenBlocks] = useState<Set<string>>(new Set());
-  const [highlight, setHighlight] = useState<string | null>(null);
-  const [packOpen, setPackOpen] = useState(false);
-  const [showFullRules, setShowFullRules] = useState(false);
-  const initBlocks = useRef(false);
-
-  useEffect(() => {
-    let alive = true;
-    getRuntimeFile(runId, "walk_actions.jsonl", 500)
-      .then((r) => alive && setWalkActions(r.records || []))
-      .catch(() => {
-        if (!alive) return;
-        setWalkActions(
-          fallbackEdges.map((e) => ({ edge_id: e.label, from_node_id: e.source, walk_status: e.status }))
-        );
-      });
-    getRuntimeFile(runId, "search_queries.jsonl", 500)
-      .then((r) => alive && setSearchQueries(r.records || []))
-      .catch(() => alive && setSearchQueries(queries));
-    getConfigRulePacks()
-      .then((r) => alive && setRulePack(((r.data.rule_packs as AnyRec[]) || [])[0] || null))
-      .catch(() => alive && setRulePack(null));
-    return () => {
-      alive = false;
-    };
-  }, [runId, fallbackEdges, queries]);
-
-  const counts = useMemo(() => {
-    const c: Record<string, number> = {};
-    (walkActions || []).forEach((a) => {
-      const e = String(a.edge_id || "");
-      if (e) c[e] = (c[e] || 0) + 1;
-    });
-    return c;
-  }, [walkActions]);
-
-  const actionsByFrom = useMemo(() => {
-    const m = new Map<string, Set<string>>();
-    (walkActions || []).forEach((a) => {
-      const from = String(a.from_node_id || "");
-      const cn = EDGE_ACTION_CN[String(a.edge_id || "")];
-      if (from && cn) {
-        if (!m.has(from)) m.set(from, new Set());
-        m.get(from)!.add(cn);
-      }
-    });
-    return m;
-  }, [walkActions]);
-
-  const videos = useMemo(() => buildVideos(contentItems.items, actionsByFrom), [contentItems.items, actionsByFrom]);
-
-  const resultTally = useMemo(() => {
-    const t = { ADD_TO_CONTENT_POOL: 0, KEEP_CONTENT_FOR_REVIEW: 0, REJECT_CONTENT: 0 } as Record<string, number>;
-    videos.forEach((v) => {
-      if (t[v.action] !== undefined) t[v.action] += 1;
-    });
-    return t;
-  }, [videos]);
-
-  // search_query_id → 原始 query(含 llm_input_evidence),供搜索块 AI 扩写 prompt 入口。
-  const queryById = useMemo(() => {
-    const m: Record<string, AnyRec> = {};
-    queries.forEach((q) => {
-      m[String(q.search_query_id)] = q;
-    });
-    return m;
-  }, [queries]);
-
-  // ===== 路径树血缘模型 =====
-  const model: TreeModel = useMemo(() => {
-    const sq = (searchQueries && searchQueries.length ? searchQueries : queries) || [];
-    const videosById = new Map(videos.map((v) => [v.id, v]));
-
-    // 作者作品(prev_step=author_works)按作者分组
-    const authorWorks = new Map<string, VideoJourney[]>();
-    videos.forEach((v) => {
-      if (v.prevStep === "author_works" && v.authorId) {
-        if (!authorWorks.has(v.authorId)) authorWorks.set(v.authorId, []);
-        authorWorks.get(v.authorId)!.push(v);
-      }
-    });
-    // 每个作者的扩展挂到"第一条触发它的视频"(优先通过/入池的)
-    const authorAttach: Record<string, string> = {};
-    authorWorks.forEach((_works, authorId) => {
-      const cand = videos.filter((v) => v.authorId === authorId && v.prevStep !== "author_works");
-      const trig = cand.find((v) => v.action === "ADD_TO_CONTENT_POOL" || v.action === "KEEP_CONTENT_FOR_REVIEW") || cand[0];
-      if (trig) authorAttach[authorId] = trig.id;
-    });
-
-    // 搜索词元数据
-    type QMeta = { qid: string; text: string; createdAt: string; prevStep: string; llmOf: string; parent: string; hashtag: string; srcVid: string };
-    const metas: QMeta[] = sq.map((q) => ({
-      qid: String(q.search_query_id || ""),
-      text: String(q.search_query || q.search_query_id || ""),
-      createdAt: String(q.created_at || ""),
-      prevStep: String(q.previous_discovery_step || ""),
-      llmOf: String(q.llm_variant_of || ""),
-      parent: String(q.parent_search_query_id || ""),
-      hashtag: String(q.hashtag || ""),
-      srcVid: String(q.source_content_id || "")
-    }));
-    const metaByQid = new Map(metas.map((m) => [m.qid, m]));
-
-    // 顶层搜索块:种子 / AI 扩写 / 话题词(prev_step 为 pattern_search_query 或 hashtag_to_query);翻页、作者作品不上顶层
-    const topMetas = metas
-      .filter((m) => m.prevStep === "pattern_search_query" || m.prevStep === "hashtag_to_query")
-      .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
-    const seqByQid: Record<string, number> = {};
-    topMetas.forEach((m, i) => (seqByQid[m.qid] = i + 1));
-
-    // 视频归属的顶层块(根视频=自己的块或翻页父块;作者作品=触发视频的块)
-    const blockOfVideo: Record<string, string> = {};
-    const rootByBlock = new Map<string, VideoJourney[]>();
-    topMetas.forEach((m) => rootByBlock.set(m.qid, []));
-    videos.forEach((v) => {
-      if (v.prevStep === "author_works") return;
-      // 该视频的 queryId 可能是翻页块(q_xxx_page_yyy),归到其父顶层块;页码从后缀解析
-      let qid = v.queryId;
-      let page = 1;
-      const vm = metaByQid.get(qid);
-      if (vm && vm.prevStep === "query_next_page" && vm.parent) {
-        qid = vm.parent;
-        const mt = v.queryId.match(/_page_0*(\d+)$/);
-        page = mt ? Number(mt[1]) : 2;
-      }
-      if (rootByBlock.has(qid)) {
-        rootByBlock.get(qid)!.push({ ...v, page });
-        blockOfVideo[v.id] = qid;
-      }
-    });
-    // 作者作品的块 = 触发视频的块
-    Object.entries(authorAttach).forEach(([authorId, trigId]) => {
-      const blk = blockOfVideo[trigId];
-      if (!blk) return;
-      (authorWorks.get(authorId) || []).forEach((w) => (blockOfVideo[w.id] = blk));
-    });
-
-    // tag 前向:源视频 → 它催生的 tag 搜索块
-    const tagFwd = new Map<string, { hashtag: string; seq: number; anchor: string; blockQid: string }>();
-    topMetas.forEach((m) => {
-      if (m.prevStep === "hashtag_to_query" && m.srcVid) {
-        tagFwd.set(m.srcVid, { hashtag: m.text, seq: seqByQid[m.qid], anchor: `wjb-${m.qid}`, blockQid: m.qid });
-      }
-    });
-
-    // 每条搜索块的来历
-    const pageCountByParent: Record<string, number> = {};
-    metas.forEach((m) => {
-      if (m.prevStep === "query_next_page" && m.parent) pageCountByParent[m.parent] = (pageCountByParent[m.parent] || 0) + 1;
-    });
-    const blocks: SearchBlock[] = topMetas.map((m) => {
-      let lineage: Lineage;
-      if (m.llmOf) {
-        const p = metaByQid.get(m.llmOf);
-        const pSeq = seqByQid[m.llmOf];
-        lineage = {
-          kind: "llm",
-          chip: `由「${p?.text || m.llmOf}」AI 扩写`,
-          jumpAnchor: `wjb-${m.llmOf}`,
-          jumpBlock: m.llmOf,
-          crumbs: [
-            { label: "需求池" },
-            { label: `搜索${pSeq} ${p?.text || m.llmOf}`, anchor: `wjb-${m.llmOf}`, blockQid: m.llmOf },
-            { label: "AI 扩写" }
-          ]
-        };
-      } else if (m.prevStep === "hashtag_to_query") {
-        const srcV = videosById.get(m.srcVid);
-        const srcBlk = blockOfVideo[m.srcVid];
-        const srcSeq = srcBlk ? seqByQid[srcBlk] : undefined;
-        lineage = {
-          kind: "tag",
-          chip: `由视频「${srcV?.title || m.srcVid}」的标签,以「${m.text}」作搜索词进一步搜索`,
-          jumpAnchor: `wjv-${m.srcVid}`,
-          jumpBlock: srcBlk,
-          crumbs: [
-            srcSeq ? { label: `搜索${srcSeq} ${metaByQid.get(srcBlk!)?.text || ""}`, anchor: `wjb-${srcBlk}`, blockQid: srcBlk } : { label: "上游搜索" },
-            { label: `视频「${srcV?.title || m.srcVid}」`, anchor: `wjv-${m.srcVid}`, blockQid: srcBlk },
-            { label: `标签 #${m.text}` }
-          ]
-        };
-      } else {
-        lineage = { kind: "seed", chip: "需求池直出", crumbs: [{ label: "需求池「本次需求」" }] };
-      }
-      return {
-        qid: m.qid,
-        seq: seqByQid[m.qid],
-        text: m.text,
-        pageCount: pageCountByParent[m.qid] || 0,
-        lineage,
-        rootVideos: (rootByBlock.get(m.qid) || []).slice().sort((a, b) => (a.page || 1) - (b.page || 1))
-      };
-    });
-
-    return { blocks, authorWorks, authorAttach, tagFwd };
-  }, [videos, searchQueries, queries]);
-
-  // 默认展开第 1 块
-  useEffect(() => {
-    if (!initBlocks.current && model.blocks.length) {
-      initBlocks.current = true;
-      setOpenBlocks(new Set([model.blocks[0].qid]));
-    }
-  }, [model.blocks]);
-
-  // 跳转并高亮某个 anchor(自动展开目标所在块)
-  function jumpTo(anchor?: string, blockQid?: string) {
-    if (!anchor) return;
-    if (blockQid) setOpenBlocks((prev) => new Set(prev).add(blockQid));
-    setHighlight(anchor);
-    setTimeout(() => {
-      document.getElementById(anchor)?.scrollIntoView({ behavior: "smooth", block: "center" });
-    }, 60);
-    setTimeout(() => setHighlight((h) => (h === anchor ? null : h)), 2200);
-  }
-
-  return (
-    <div className="walk-journey">
-      <div className="wj-viewbar">
-        <div className="wj-toggle">
-          <button type="button" className={view === "tree" ? "active" : ""} onClick={() => setView("tree")}>
-            <ListTree size={15} /> 路径树
-          </button>
-          <button type="button" className={view === "map" ? "active" : ""} onClick={() => setView("map")}>
-            <Network size={15} /> 地图
-          </button>
-        </div>
-        <button type="button" className="wj-pack-btn" onClick={() => setPackOpen((v) => !v)}>
-          <ShieldCheck size={15} /> 内容发现判断规则包 {packOpen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
-        </button>
-      </div>
-
-      {packOpen ? (
-        <RulePackBody pack={rulePack} showFull={showFullRules} onToggleFull={() => setShowFullRules((v) => !v)} />
-      ) : null}
-
-      {view === "map" ? (
-        <WalkMap counts={counts} videoCount={videos.length} tally={resultTally} />
-      ) : (
-        <WalkTree
-          model={model}
-          queryById={queryById}
-          openVideo={openVideo}
-          setOpenVideo={setOpenVideo}
-          openBlocks={openBlocks}
-          setOpenBlocks={setOpenBlocks}
-          highlight={highlight}
-          jumpTo={jumpTo}
-          onOpen={onOpen}
-          onRule={() => setPackOpen(true)}
-        />
-      )}
-    </div>
-  );
-}
-
-const AI_GATES = new Set(["not_fit_senior", "low_confidence", "judge_failed"]);
-
-export function RulePackBody({
-  pack,
-  showFull,
-  onToggleFull
-}: {
-  pack: AnyRec | null;
-  showFull: boolean;
-  onToggleFull: () => void;
-}) {
-  const gates = ((pack?.hard_gates as AnyRec[]) || []);
-  const gateCn = (g: AnyRec) => GATE_CN[String(g.gate_id)] || String(g.label || g.gate_id);
-  const baseReject = gates.filter((g) => g.decision_action === "REJECT_CONTENT" && !AI_GATES.has(String(g.gate_id)));
-  const dims = (((pack?.scorecard as AnyRec)?.dimensions as AnyRec[]) || []).filter((d) => d.runtime_status === "active");
-  const thresholds = ((pack?.thresholds as AnyRec[]) || [])
-    .slice()
-    .sort((a, b) => Number(b.min_score) - Number(a.min_score));
-  const thresholdText = thresholds
-    .map((t, i) => {
-      const cn = RESULT_META[String(t.decision_action)]?.cn || String(t.decision_action);
-      const min = Number(t.min_score);
-      if (i === 0) return `≥${min} ${cn}`;
-      if (min === 0) return `<${Number(thresholds[i - 1].min_score)} ${cn}`;
-      return `${min}–${Number(thresholds[i - 1].min_score) - 1} ${cn}`;
-    })
-    .join(",");
-  return (
-    <div className="wj-rulepack-body">
-      <div className="wj-rule-hint">这个规则包怎么判(运营能看到的口径):</div>
-      <div className="wj-rule-tier">
-        <span className="wj-tier-tag reject">一票否决</span>
-        <span>{baseReject.map(gateCn).join(" / ")} → 直接淘汰</span>
-      </div>
-      <div className="wj-rule-tier">
-        <span className="wj-tier-tag ai">Gemini 判定</span>
-        <span>
-          不适合 50+,或判定信心不足(低于 60%)→ 淘汰;Gemini 判定失败 → 转待复看
-        </span>
-      </div>
-      <div className="wj-rule-tier">
-        <span className="wj-tier-tag score">打分</span>
-        <span>
-          {dims.map((d) => `${d.label}(${d.max_score} 分)`).join(" + ")}:{thresholdText}
-        </span>
-      </div>
-      <button type="button" className="text-button wj-fullrules-btn" onClick={onToggleFull}>
-        {showFull ? "收起完整规则" : "看完整规则"}
-      </button>
-      {showFull ? (
-        <table className="wj-rule-table">
-          <thead>
-            <tr><th>硬门槛</th><th>命中后</th></tr>
-          </thead>
-          <tbody>
-            {gates.map((g, i) => (
-              <tr key={String(g.gate_id || i)}>
-                <td>{gateCn(g)}</td>
-                <td>{RESULT_META[String(g.decision_action)]?.cn || (String(g.decision_action) === "DO_NOT_EXPAND_AUTHOR" ? "不扩展作者" : String(g.decision_action))}</td>
-              </tr>
-            ))}
-          </tbody>
-        </table>
-      ) : null}
-    </div>
-  );
-}
-
-// ===== B 地图:固定坐标手绘有向图(节点位置写死,不需要排版引擎)=====
-type MapNode = { cx: number; cy: number; label: string; accent?: boolean };
-const MAP_NODES: Record<string, MapNode> = {
-  demand: { cx: 70, cy: 180, label: "需求" },
-  query: { cx: 212, cy: 180, label: "搜索词" },
-  video: { cx: 362, cy: 180, label: "视频·判定", accent: true },
-  author: { cx: 566, cy: 80, label: "作者主页" },
-  pool: { cx: 566, cy: 180, label: "内容池" },
-  hashtag: { cx: 362, cy: 300, label: "话题" },
-  stop: { cx: 566, cy: 300, label: "停止 / 降档" }
-};
-const NW = 96;
-const NH = 38;
-type MapEdge = {
-  d?: string;
-  x1?: number; y1?: number; x2?: number; y2?: number;
-  label: string; rule?: string; lx: number; ly: number;
-  edges: string[]; base?: boolean; tone: "base" | "pass" | "reject";
-};
-const MAP_EDGES: MapEdge[] = [
-  { x1: 118, y1: 180, x2: 162, y2: 180, label: "生成搜索词", lx: 140, ly: 172, edges: [], base: true, tone: "base" },
-  { x1: 260, y1: 180, x2: 312, y2: 180, label: "搜索到视频", lx: 286, ly: 172, edges: [], base: true, tone: "base" },
-  { d: "M194,162 C194,128 230,128 230,162", label: "翻页", lx: 212, ly: 120, edges: ["query_next_page"], tone: "base" },
-  { x1: 400, y1: 166, x2: 526, y2: 92, label: "进作者主页", rule: "入池放行 / 待复看减半", lx: 474, ly: 116, edges: ["video_to_author", "author_to_works", "author_work_to_content"], tone: "pass" },
-  { x1: 410, y1: 180, x2: 518, y2: 180, label: "收入内容池", rule: "判定达标", lx: 464, ly: 172, edges: ["decision_to_asset"], tone: "pass" },
-  { x1: 362, y1: 199, x2: 362, y2: 281, label: "拆话题", rule: "仅入池视频", lx: 320, ly: 248, edges: ["video_to_hashtag"], tone: "pass" },
-  { d: "M314,300 C234,300 212,256 212,199", label: "话题回搜", lx: 244, ly: 290, edges: ["hashtag_to_query"], tone: "pass" },
-  { x1: 412, y1: 200, x2: 522, y2: 286, label: "判定没过 / 额度尽", rule: "停止 · 降档", lx: 496, ly: 270, edges: ["path_stop", "budget_downgrade"], tone: "reject" }
-];
-const TONE_COLOR: Record<string, string> = { base: "#5b6678", pass: "#1d9e75", reject: "#d85a30" };
-const OFF_COLOR = "#cbd3df";
-
-function WalkMap({ counts, videoCount, tally }: { counts: Record<string, number>; videoCount: number; tally: Record<string, number> }) {
-  return (
-    <div className="wj-map">
-      <svg viewBox="0 0 660 360" width="100%" role="img" aria-label="游走地图:从需求到搜索词到视频判定,通过则进作者主页/拆话题/收入池,没过则停止">
-        <defs>
-          {Object.entries(TONE_COLOR).map(([k, c]) => (
-            <marker key={k} id={`wm-${k}`} markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto" markerUnits="strokeWidth">
-              <path d="M0,0 L6,3 L0,6 Z" fill={c} />
-            </marker>
-          ))}
-          <marker id="wm-off" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto" markerUnits="strokeWidth">
-            <path d="M0,0 L6,3 L0,6 Z" fill={OFF_COLOR} />
-          </marker>
-        </defs>
-        {MAP_EDGES.map((e, i) => {
-          const n = e.edges.reduce((s, id) => s + (counts[id] || 0), 0);
-          const active = e.base || n > 0;
-          const color = active ? TONE_COLOR[e.tone] : OFF_COLOR;
-          const marker = active ? `url(#wm-${e.tone})` : "url(#wm-off)";
-          const main = `${e.label}${n > 0 ? ` ×${n}` : ""}`;
-          return (
-            <g key={i}>
-              {e.d ? (
-                <path d={e.d} fill="none" stroke={color} strokeWidth={1.6} strokeDasharray={active ? "0" : "4 4"} markerEnd={marker} />
-              ) : (
-                <line x1={e.x1} y1={e.y1} x2={e.x2} y2={e.y2} stroke={color} strokeWidth={1.6} strokeDasharray={active ? "0" : "4 4"} markerEnd={marker} />
-              )}
-              <text x={e.lx} y={e.ly} textAnchor="middle" fontSize={11.5} fontWeight={600} fill={active ? "#3a4658" : "#9aa4b5"} style={{ paintOrder: "stroke", stroke: "#f6f8fb", strokeWidth: 3 }}>
-                {main}
-                {e.rule ? <tspan x={e.lx} dy={13} fontSize={10} fontWeight={400} fill="#9aa4b5">{e.rule}</tspan> : null}
-              </text>
-            </g>
-          );
-        })}
-        {Object.values(MAP_NODES).map((nd) => (
-          <g key={nd.label}>
-            <rect
-              x={nd.cx - NW / 2} y={nd.cy - NH / 2} width={NW} height={NH} rx={9}
-              fill={nd.accent ? "#eaf3ff" : "#ffffff"} stroke={nd.accent ? "#2360ad" : "#c2cbd9"} strokeWidth={nd.accent ? 1.5 : 1}
-            />
-            <text x={nd.cx} y={nd.cy + 4} textAnchor="middle" fontSize={12.5} fontWeight={600} fill={nd.accent ? "#2360ad" : "#22304a"}>{nd.label}</text>
-          </g>
-        ))}
-      </svg>
-      <div className="wj-map-legend">
-        <span><i className="wm-dot pass" /> 判定通过的扩展</span>
-        <span><i className="wm-dot reject" /> 没过 · 刹车</span>
-        <span><i className="wm-dot base" /> 基础流转</span>
-        <span><i className="wm-dot off" /> 本次未走(虚线)</span>
-        <span className="wj-map-tally">本次:{videoCount} 条视频 → 入池 {tally.ADD_TO_CONTENT_POOL} / 待复看 {tally.KEEP_CONTENT_FOR_REVIEW} / 淘汰 {tally.REJECT_CONTENT}</span>
-      </div>
-    </div>
-  );
-}
-
-// 评分明细(复用 judge 的 scorecard 渲染),空 dims 不渲染。
-function ScorecardDetail({ scorecard }: { scorecard?: AnyRec }) {
-  const dims = (scorecard?.dimensions as AnyRec[]) || [];
-  if (!dims.length) return null;
-  const DEPRECATED = new Set(["content_audience_profile", "interaction_performance", "freshness_available", "douyin_tone", "adaptability"]);
-  return (
-    <div className="scorecard-row">
-      <span className="scorecard-label">评分</span>
-      {dims.map((d, i) => {
-        const key = String(d.key || "");
-        const dep = DEPRECATED.has(key);
-        return (
-          <span className={`scorecard-chip${dep ? " deprecated" : ""}`} key={key || i}>
-            {key}:{d.score == null ? "—" : String(d.score)}
-            {d.max_score != null ? ` / ${String(d.max_score)}` : ""}
-            {dep ? "(弃用)" : ""}
-          </span>
-        );
-      })}
-    </div>
-  );
-}
-
-// 视频播放器(懒加载 iframe,仅抖音有 embed;复用 content-card-video CSS),无 ID 不渲染。
-function VideoDetailMedia({ item }: { item: AnyRec }) {
-  const [videoOn, setVideoOn] = useState(false);
-  const contentId = String(item.platform_content_id || "");
-  if (!contentId) return null;
-  const pLabel = platformLabel(item.platform);
-  const playerUrl = embedPlayerUrl(item);
-  const origin = contentUrl(item);
-  return (
-    <div className="wj-vid-media">
-      <div className="content-card-video">
-        {!playerUrl ? (
-          <div className="content-video-placeholder muted">{pLabel}无公开嵌入播放器</div>
-        ) : videoOn ? (
-          <iframe
-            className="content-video-frame"
-            src={playerUrl}
-            title={`${pLabel}视频 ${contentId}`}
-            loading="lazy"
-            allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
-            allowFullScreen
-          />
-        ) : (
-          <button type="button" className="content-video-placeholder content-video-load" onClick={() => setVideoOn(true)}>
-            <PlayCircle size={32} />
-            <span>点击播放视频</span>
-          </button>
-        )}
-      </div>
-      {origin ? (
-        <a className="text-button wj-media-origin" href={origin} target="_blank" rel="noreferrer">
-          打开原帖
-        </a>
-      ) : null}
-    </div>
-  );
-}
-
-// ===== A 路径树:搜索块竖排 + 来历回指 + 作者作品递进嵌套 =====
-function WalkTree({
-  model,
-  queryById,
-  openVideo,
-  setOpenVideo,
-  openBlocks,
-  setOpenBlocks,
-  highlight,
-  jumpTo,
-  onOpen,
-  onRule
-}: {
-  model: TreeModel;
-  queryById: Record<string, AnyRec>;
-  openVideo: string | null;
-  setOpenVideo: (id: string | null) => void;
-  openBlocks: Set<string>;
-  setOpenBlocks: (fn: (prev: Set<string>) => Set<string>) => void;
-  highlight: string | null;
-  jumpTo: (anchor?: string, blockQid?: string) => void;
-  onOpen: (payload: Record<string, unknown>, kind?: "walk" | "technical") => void;
-  onRule: () => void;
-}) {
-  function toggleBlock(qid: string) {
-    setOpenBlocks((prev) => {
-      const n = new Set(prev);
-      if (n.has(qid)) n.delete(qid);
-      else n.add(qid);
-      return n;
-    });
-  }
-
-  function renderVideo(v: VideoJourney, visited: Set<string>): ReactElement | null {
-    if (visited.has(v.id)) return null;
-    const nextVisited = new Set(visited).add(v.id);
-    const meta = RESULT_META[v.action] || { cn: v.action || "未判定", cls: "muted", outcome: "" };
-    const open = openVideo === v.id;
-    const fwd = model.tagFwd.get(v.id);
-    const showAuthor = model.authorAttach[v.authorId] === v.id;
-    const works = showAuthor ? model.authorWorks.get(v.authorId) || [] : [];
-    return (
-      <div className="wj-vid" id={`wjv-${v.id}`} key={v.id || v.title}>
-        <button type="button" className={`wj-vid-head${highlight === `wjv-${v.id}` ? " wj-hl" : ""}`} onClick={() => setOpenVideo(open ? null : v.id)}>
-          {open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
-          <PlayCircle size={15} />
-          <span className="wj-vid-title">{v.title}</span>
-          <span className={`wj-result-badge ${meta.cls}`}>{meta.cn}</span>
-        </button>
-
-        {open ? (
-          <div className="wj-vid-detail">
-            <div className="wj-vid-main">
-              <div className="wj-step">
-                <span className="wj-step-tag">判定</span>
-                <span>
-                  {v.judgeStatus === "gemini_quota_exhausted"
-                    ? "⚠ Gemini 配额截断,本条未判定"
-                    : `Gemini:适合 50+ ${v.fit === true ? "是" : v.fit === false ? "否" : "—"} · 置信度 ${v.conf ?? "—"} · 相关性 ${v.rel ?? "—"}`}
-                  <button type="button" className="wj-pack-link" onClick={onRule}>
-                    <ShieldCheck size={13} /> 看判定规则
-                  </button>
-                </span>
-              </div>
-              {v.reason ? (
-                <div className="wj-step">
-                  <span className="wj-step-tag">理由</span>
-                  <span className="wj-reason">{v.reason}</span>
-                </div>
-              ) : null}
-              <ScorecardDetail scorecard={v.scorecard} />
-            </div>
-            <div className="wj-vid-side">
-              <VideoDetailMedia item={v.item} />
-            </div>
-          </div>
-        ) : null}
-
-        {/* 下游动作(展开块时常显)*/}
-        <div className="wj-vid-acts">
-          {v.action === "ADD_TO_CONTENT_POOL" ? (
-            <div className="wj-act pass"><CornerDownRight size={13} /><Database size={13} /> 收入内容池</div>
-          ) : null}
-          {fwd ? (
-            <button type="button" className="wj-act fwd" onClick={() => jumpTo(fwd.anchor, fwd.blockQid)}>
-              <CornerDownRight size={13} /><Hash size={13} /> 拆出话题 #{fwd.hashtag},以「{fwd.hashtag}」作搜索词进一步搜索 → 搜索{fwd.seq}
-            </button>
-          ) : null}
-          {showAuthor && works.length ? (
-            <div className="wj-act author">
-              <div className="wj-act-head"><CornerDownRight size={13} /><UserSearch size={13} /> 进作者主页「{v.author}」· 取 {works.length} 作品 → 逐条判定</div>
-              <div className="wj-author-works">{works.map((w) => renderVideo(w, nextVisited))}</div>
-            </div>
-          ) : null}
-        </div>
-      </div>
-    );
-  }
-
-  const crumb = (c: Crumb, i: number) => (
-    <span key={i}>
-      {i > 0 ? <ChevronRight size={11} className="wj-crumb-sep" /> : null}
-      {c.anchor ? (
-        <button type="button" className="wj-crumb-link" onClick={() => jumpTo(c.anchor, c.blockQid)}>{c.label}</button>
-      ) : (
-        <span className="wj-crumb-text">{c.label}</span>
-      )}
-    </span>
-  );
-
-  return (
-    <div className="wj-tree">
-      <div className="wj-tree-root">
-        <Target size={16} /> <strong>需求池</strong> · 本次发现起点(demand agent;下面按搜索先后竖排)
-      </div>
-      <div className="wj-blocks">
-        {model.blocks.map((b) => {
-          const open = openBlocks.has(b.qid);
-          return (
-            <div className={`wj-block${highlight === `wjb-${b.qid}` ? " wj-hl" : ""}`} id={`wjb-${b.qid}`} key={b.qid}>
-              <button type="button" className="wj-block-head" onClick={() => toggleBlock(b.qid)}>
-                {open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
-                <span className="wj-seq">{b.seq}</span>
-                <span
-                  className={`wj-origin-chip ${b.lineage.kind}${b.lineage.jumpAnchor ? " clickable" : ""}`}
-                  onClick={(e) => {
-                    if (b.lineage.jumpAnchor) {
-                      e.stopPropagation();
-                      jumpTo(b.lineage.jumpAnchor, b.lineage.jumpBlock);
-                    }
-                  }}
-                >
-                  <ArrowUpLeft size={12} /> {b.lineage.chip}
-                </span>
-                <Search size={15} />
-                <strong className="wj-block-title">搜索「{b.text}」</strong>
-                <span className="wj-q-count">{b.rootVideos.length} 条视频</span>
-              </button>
-              <div className="wj-crumbs">
-                {b.lineage.crumbs.map(crumb)}
-                {b.lineage.kind === "llm" && queryById[b.qid] ? (
-                  <button
-                    type="button"
-                    className="wj-crumb-link wj-prompt-link"
-                    onClick={() => onOpen(llmVariantPromptPayload(queryById[b.qid]), "technical")}
-                  >
-                    · 查看 AI 扩写提示词
-                  </button>
-                ) : null}
-              </div>
-              {open ? (
-                <div className="wj-block-body">
-                  {b.rootVideos.length
-                    ? b.rootVideos.map((v, i) => {
-                        const prevPage = i > 0 ? b.rootVideos[i - 1].page || 1 : 1;
-                        const page = v.page || 1;
-                        const divider = page > 1 && page !== prevPage ? (
-                          <div className="wj-page-divider" key={`pd-${page}`}>
-                            <RotateCw size={13} /> 翻页 → 第 {page} 页
-                          </div>
-                        ) : null;
-                        return (
-                          <div key={v.id || v.title} className={page > 1 ? "wj-page-group" : undefined}>
-                            {divider}
-                            {renderVideo(v, new Set())}
-                          </div>
-                        );
-                      })
-                    : <div className="empty-state">这条搜索没有发现视频。</div>}
-                </div>
-              ) : null}
-            </div>
-          );
-        })}
-        {!model.blocks.length ? <div className="empty-state">正在加载游走血缘…</div> : null}
-      </div>
-    </div>
-  );
-}

+ 0 - 62
web/features/runs/queryPrompt.ts

@@ -1,62 +0,0 @@
-// AI 扩写(LLM variant)搜索词的提示词解释:从 RunDashboardPage 抽出,供发现旅程视频详情复用。
-import { compactValue } from "@/lib/status/status";
-
-export function isLlmVariantQuery(query: Record<string, unknown>): boolean {
-  return query.search_query_generation_method === "llm_variant";
-}
-
-export function seedTermForQuery(query: Record<string, unknown>): unknown {
-  const seedRef =
-    query.pattern_seed_ref && typeof query.pattern_seed_ref === "object"
-      ? (query.pattern_seed_ref as Record<string, unknown>)
-      : {};
-  const llmInput =
-    query.llm_input_evidence && typeof query.llm_input_evidence === "object"
-      ? (query.llm_input_evidence as Record<string, unknown>)
-      : {};
-  return llmInput.seed_term || seedRef.seed_term || query.search_query;
-}
-
-export function llmVariantPromptPayload(query: Record<string, unknown>): Record<string, unknown> {
-  const llmInput =
-    query.llm_input_evidence && typeof query.llm_input_evidence === "object"
-      ? (query.llm_input_evidence as Record<string, unknown>)
-      : {};
-  const seedTerm = seedTermForQuery(query);
-  return {
-    "这个提示词在做什么": "拿一个 DemandAgent 种子词,结合 Pattern 证据,让 LLM 生成一条相邻但不重复的抖音搜索 query。",
-    "种子词": seedTerm,
-    "生成结果": query.search_query,
-    "父 query": query.llm_variant_of || "无",
-    "提示词版本": query.llm_prompt_version || "query_variant.v1",
-    "LLM 模型": query.llm_generation_model || "缺失",
-    messages: [
-      {
-        role: "system",
-        content:
-          "You generate one concise Chinese short-video search query. " +
-          "Return exactly one plain query string. Do not return JSON, " +
-          "lists, quotes, explanations, or multiple lines."
-      },
-      {
-        role: "user",
-        content: [
-          "Seed term:",
-          compactValue(seedTerm),
-          "",
-          "Evidence context:",
-          JSON.stringify(llmInput, null, 2),
-          "",
-          "Create one adjacent search phrase that stays faithful to the evidence. Avoid any phrase listed in existing_search_queries."
-        ].join("\n")
-      }
-    ],
-    "输入证据摘要": {
-      seed_terms: llmInput.seed_terms,
-      itemset_ids: llmInput.itemset_ids,
-      category_bindings: llmInput.category_bindings,
-      element_bindings: llmInput.element_bindings,
-      existing_search_queries: llmInput.existing_search_queries
-    }
-  };
-}

+ 0 - 45
web/lib/adapters/pipeline.ts

@@ -1,45 +0,0 @@
-import type { DashboardResponse } from "@/lib/api/types";
-
-export type PipelineStage = {
-  id: string;
-  label: string;
-  status: "active" | "warn" | "plain";
-  count?: number;
-};
-
-// stage_conclusions.status -> 导航卡状态。failed/blocked 必须可见为 warn,
-// 不再只按「有无数据」判断(V1 bug: 失败阶段曾显示为正常)。
-function fromConclusion(status: string | undefined, fallback: "active" | "warn" | "plain"): "active" | "warn" | "plain" {
-  if (!status) return fallback;
-  if (["failed", "fail", "rule_blocked", "error", "missing"].includes(status)) return "warn";
-  if (["success", "pass", "partial_success", "pending", "review"].includes(status)) return "active";
-  return fallback;
-}
-
-export function pipelineStages(dashboard?: DashboardResponse): PipelineStage[] {
-  const counts = dashboard?.counts || {};
-  const files = dashboard?.files || {};
-  const conclusionByStage = new Map(
-    (dashboard?.stage_conclusions || []).map((row) => [row.stage_id, row.status])
-  );
-  const stage = (
-    id: string,
-    label: string,
-    fallback: "active" | "warn" | "plain",
-    count?: number
-  ): PipelineStage => ({
-    id,
-    label,
-    status: fromConclusion(conclusionByStage.get(id), fallback),
-    ...(count === undefined ? {} : { count })
-  });
-  return [
-    stage("source", "数据源", files["source_context.json"] ? "active" : "warn"),
-    stage("query", "Query", counts.queries ? "active" : "warn", counts.queries),
-    stage("platform", "平台 / 内容", counts.discovered_content_items ? "active" : "warn", counts.discovered_content_items),
-    stage("judge", "判断", counts.rule_decisions ? "active" : "warn", counts.rule_decisions),
-    stage("walk", "游走", counts.walk_actions ? "active" : "plain", counts.walk_actions),
-    stage("asset", "资产沉淀", files["final_output.json"] ? "active" : "plain"),
-    stage("learning", "策略学习", files["strategy_review.json"] ? "active" : "plain")
-  ];
-}

+ 0 - 102
web/lib/api/client.ts

@@ -1,102 +0,0 @@
-import type {
-  ConfigFileResponse,
-  ContentItemsResponse,
-  DashboardResponse,
-  PlatformCatalogResponse,
-  QueryListResponse,
-  RunListResponse,
-  RuntimeFileResponse,
-  RuntimeFilesResponse,
-  TimelineResponse
-} from "./types";
-
-const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
-
-export class ApiError extends Error {
-  status: number;
-  detail: unknown;
-
-  constructor(message: string, status: number, detail: unknown) {
-    super(message);
-    this.name = "ApiError";
-    this.status = status;
-    this.detail = detail;
-  }
-}
-
-export function apiBaseUrl() {
-  return (
-    process.env.NEXT_PUBLIC_CONTENTFIND_API_BASE_URL ||
-    process.env.VITE_CONTENTFIND_API_BASE_URL ||
-    DEFAULT_API_BASE_URL
-  ).replace(/\/$/, "");
-}
-
-async function request<T>(path: string): Promise<T> {
-  const response = await fetch(`${apiBaseUrl()}${path}`, {
-    headers: { Accept: "application/json" },
-    cache: "no-store"
-  });
-  if (!response.ok) {
-    let detail: unknown = null;
-    try {
-      detail = await response.json();
-    } catch {
-      detail = await response.text();
-    }
-    throw new ApiError(`request failed: ${path}`, response.status, detail);
-  }
-  return response.json() as Promise<T>;
-}
-
-export function listRuns(params: URLSearchParams) {
-  const query = params.toString();
-  return request<RunListResponse>(`/runs${query ? `?${query}` : ""}`);
-}
-
-export function getDashboard(runId: string) {
-  return request<DashboardResponse>(`/runs/${encodeURIComponent(runId)}/dashboard`);
-}
-
-export function getQueries(runId: string) {
-  return request<QueryListResponse>(`/runs/${encodeURIComponent(runId)}/queries`);
-}
-
-export function getTimeline(runId: string) {
-  return request<TimelineResponse>(`/runs/${encodeURIComponent(runId)}/timeline`);
-}
-
-export function getContentItems(runId: string) {
-  return request<ContentItemsResponse>(`/runs/${encodeURIComponent(runId)}/content-items`);
-}
-
-export function getRuntimeFiles(runId: string) {
-  return request<RuntimeFilesResponse>(`/runs/${encodeURIComponent(runId)}/runtime-files`);
-}
-
-export function getRuntimeFile(runId: string, filename: string, limit = 100) {
-  const safeFilename = encodeURIComponent(filename);
-  return request<RuntimeFileResponse>(
-    `/runs/${encodeURIComponent(runId)}/runtime-files/${safeFilename}?limit=${limit}`
-  );
-}
-
-export function getConfigRulePacks() {
-  return request<ConfigFileResponse>("/config/rule-packs");
-}
-
-export function getConfigWalkStrategy() {
-  return request<ConfigFileResponse>("/config/walk-strategy");
-}
-
-export function getConfigQueryPrompts() {
-  return request<ConfigFileResponse>("/config/query-prompts");
-}
-
-export function getConfigWalkPolicy() {
-  return request<ConfigFileResponse>("/config/walk-policy");
-}
-
-export function getConfigPlatforms() {
-  return request<PlatformCatalogResponse>("/config/platforms");
-}

+ 0 - 212
web/lib/api/types.ts

@@ -1,212 +0,0 @@
-export type DataOrigin = "production_db" | "runtime_export" | "mixed_with_runtime_export";
-
-export type RunListItem = {
-  run_id: string;
-  policy_run_id?: string | null;
-  status?: string | null;
-  current_step?: string | null;
-  platform?: string | null;
-  platform_mode?: string | null;
-  content_format?: string | null;
-  strategy_version?: string | null;
-  demand_name?: string | null;
-  demand_desc?: string | null;
-  validation_status?: string | null;
-  error_code?: string | null;
-  started_at?: string | null;
-  completed_at?: string | null;
-};
-
-export type RunListResponse = {
-  items: RunListItem[];
-  page: number;
-  page_size: number;
-  total: number;
-  data_origin: DataOrigin;
-};
-
-export type DashboardResponse = {
-  run_id: string;
-  summary: Record<string, unknown>;
-  counts: Record<string, number>;
-  files: Record<string, boolean>;
-  runtime_files: RuntimeFileStatus[];
-  validation: Record<string, unknown>;
-  final_output_summary: Record<string, unknown>;
-  strategy_review_status: string;
-  business_summary: BusinessSummary;
-  stage_conclusions: StageConclusion[];
-  rule_application_summary: RuleApplicationSummary[];
-  walk_graph: WalkGraph;
-  primary_failure_reason?: Record<string, unknown> | null;
-  technical_refs: Record<string, unknown>;
-  data_origin: DataOrigin;
-  links: Record<string, string>;
-};
-
-export type BusinessSummary = {
-  headline: string;
-  status: string;
-  source_label: string;
-  query_count: number;
-  content_count: number;
-  kept_count: number;
-  review_count: number;
-  rejected_count: number;
-  asset_count: number;
-  primary_failure_reason?: Record<string, unknown> | null;
-};
-
-export type StageConclusion = {
-  stage_id: string;
-  label: string;
-  status: string;
-  headline: string;
-  detail: string;
-  metric: string;
-};
-
-export type RuleApplicationSummary = {
-  decision_id?: string | null;
-  content_title?: string | null;
-  platform_content_id?: string | null;
-  rule_pack?: string | null;
-  hard_gate_status?: string | null;
-  score?: number | string | null;
-  decision_action?: string | null;
-  decision_reason_code?: string | null;
-  content_effect_status?: string | null;
-  primary_reason?: string | null;
-  technical_ref?: Record<string, unknown>;
-};
-
-export type WalkGraphNode = {
-  id: string;
-  type: string;
-  label: string;
-  status: string;
-};
-
-export type WalkGraphEdge = {
-  id: string;
-  source: string;
-  target: string;
-  label?: string | null;
-  status?: string | null;
-  rule_pack?: string | null;
-  rule_pack_executed?: boolean | null;
-  executed_rule_pack_id?: string | null;
-  budget_tier?: string | null;
-  reason_code?: string | null;
-};
-
-export type WalkGraph = {
-  nodes: WalkGraphNode[];
-  edges: WalkGraphEdge[];
-  source_path_count: number;
-};
-
-export type RuntimeFileStatus = {
-  filename: string;
-  exists: boolean;
-  record_count: number;
-  file_type: "json" | "jsonl";
-  contract_status: string;
-  data_origin: DataOrigin;
-};
-
-export type QueryListResponse = {
-  run_id: string;
-  items: Array<Record<string, unknown>>;
-  total: number;
-  data_origin: DataOrigin;
-};
-
-export type TimelineSummary = {
-  total_duration_ms: number | null;
-  stage_duration_ms: Record<string, number>;
-  query_failure_count: number;
-  platform_rate_limited_count: number;
-  // V2 decode 历史字段:V3 run 为空 dict,仅老 run 回看时有值。
-  decode_status_counts?: Record<string, number>;
-  error_counts: Record<string, number>;
-  walk_status_counts: Record<string, number>;
-};
-
-export type TimelineItem = {
-  source: string;
-  stage?: string | null;
-  event_type?: string | null;
-  status?: string | null;
-  timestamp?: string | null;
-  error_code?: string | null;
-  walk_action_id?: string | null;
-  record: Record<string, unknown>;
-};
-
-export type TimelineResponse = {
-  run_id: string;
-  items: TimelineItem[];
-  total: number;
-  data_origin: DataOrigin;
-  summary: TimelineSummary;
-};
-
-export type ContentItemsResponse = {
-  run_id: string;
-  items: Array<Record<string, unknown>>;
-  total: number;
-  data_origin: DataOrigin;
-};
-
-export type RuntimeFilesResponse = {
-  run_id: string;
-  files: RuntimeFileStatus[];
-  data_origin: DataOrigin;
-};
-
-export type RuntimeFileResponse = {
-  run_id: string;
-  filename: string;
-  data_origin: DataOrigin;
-  data?: Record<string, unknown>;
-  records?: Array<Record<string, unknown>>;
-  offset?: number;
-  limit?: number;
-  total?: number;
-};
-
-export type ConfigFileResponse = {
-  source_file: string;
-  data: Record<string, unknown>;
-};
-
-// 平台展示目录(/config/platforms):从 platform_profiles 派生,前端按此渲染平台名与互动指标。
-export type PlatformDescriptor = {
-  platform: string;
-  label: string;
-  status?: string | null;
-  heat_fields: string[];
-};
-
-export type PlatformCatalogResponse = {
-  platforms: Record<string, PlatformDescriptor>;
-};
-
-// walk_policy.json 的拍板值可能裸值,也可能带 {value, provenance, tbd} 留痕包裹。
-export type PolicyValue =
-  | string
-  | number
-  | boolean
-  | null
-  | { value: string | number | boolean | null; provenance?: string; tbd?: boolean; note?: string };
-
-export type WalkPolicyData = {
-  global?: Record<string, PolicyValue>;
-  edge_budgets?: Array<Record<string, PolicyValue>>;
-  edge_permissions?: Record<string, Record<string, PolicyValue> | string>;
-  budget_tiers?: Record<string, PolicyValue>;
-  dedup?: Record<string, PolicyValue>;
-  reseed_quality_gate?: Record<string, PolicyValue>;
-  [key: string]: unknown;
-};

+ 0 - 98
web/lib/config/fieldLabels.ts

@@ -1,98 +0,0 @@
-// 字段中文业务含义。来源(权威,勿自行编写):
-// - tech_documents/规则包映射/规则包映射配置表.xlsx 的「字段注释」sheet 与各 sheet 数据字典行
-// - tech_documents/游走策略/游走策略配置表.xlsx 各 sheet 数据字典行
-// - query prompts 字段含义来自 M2 implementation brief(prompt 配置合同)
-export type FieldAnnotation = { label: string; desc: string };
-
-export const FIELD_ANNOTATIONS: Record<string, FieldAnnotation> = {
-  // ===== 通用 =====
-  rule_pack_id: { label: "规则包 ID", desc: "规则包 ID,用来标识某个具体规则包。" },
-  rule_pack_version: { label: "规则包版本", desc: "规则包版本。" },
-  priority: { label: "优先级", desc: "优先级,数字越小越先执行或越先作为主原因。" },
-  enabled: { label: "是否启用", desc: "这条配置是否存在并允许维护,不等于当前运行启用。" },
-  notes: { label: "注释", desc: "中文业务注释。" },
-  platform: { label: "平台", desc: "规则适用的平台,例如抖音。" },
-  field_path: { label: "字段路径", desc: "在 EvidenceBundle 或运行上下文中的字段路径。" },
-  operator: { label: "操作符", desc: "判断操作符,例如 is_empty、in、not_in、eq、gte。" },
-  expected_value: { label: "目标值", desc: "触发规则时匹配的目标值。" },
-  decision_action: { label: "判定动作", desc: "规则输出动作,例如入池、待复看、淘汰。" },
-  decision_reason_code: { label: "原因码", desc: "规则输出原因码,用于复盘和报表。" },
-  effect_status: { label: "效果状态", desc: "内容级效果状态,例如 success、pending、failed、rule_blocked。" },
-  target_entity: { label: "判断对象", desc: "规则判断对象,例如内容、作者、tag、路径或预算。" },
-  runtime_stage: { label: "适用阶段", desc: "适用阶段,例如 V1.0 或 future。" },
-  strategy_version: { label: "策略版本", desc: "策略版本,用来区分不同阶段或不同实验版本。" },
-  edge_id: { label: "游走边", desc: "游走边 ID(绑定/预算/停止规则适用的边)。" },
-
-  // ===== rule_pack_dispatch =====
-  dispatch_id: { label: "路由 ID", desc: "规则路由 ID,用来标识一条选择规则包的路由。" },
-  content_format: { label: "内容形态", desc: "内容形态,例如 video。" },
-  dispatch_enabled: { label: "运行时启用", desc: "当前运行链路是否真正会选择这条规则;false 即 future 包(已归属未运行)。" },
-  fallback_policy: { label: "兜底策略", desc: "路由失败或多条路由同时命中时的处理策略。" },
-
-  // ===== hard_gate_rules =====
-  gate_id: { label: "硬规则 ID", desc: "Hard Gate 规则 ID。" },
-  label: { label: "中文名称", desc: "Hard Gate 中文名称。" },
-  when: { label: "触发条件", desc: "字段路径 + 操作符 + 目标值组成的触发条件。" },
-  severity: { label: "严重程度", desc: "规则严重程度,例如 fatal 表示命中后强拦截,review 表示进入待复看。" },
-  stop_scoring: { label: "停止评分", desc: "命中后是否停止后续评分。" },
-
-  // ===== scorecard =====
-  key: { label: "维度 key", desc: "评分维度 key。" },
-  dimension_key: { label: "所属维度", desc: "评分维度 key。" },
-  max_score: { label: "最高分", desc: "该维度或阈值最高分。" },
-  weight_percent: { label: "权重 %", desc: "该维度在总分中的权重。" },
-  runtime_status: { label: "运行状态", desc: "运行状态,例如 active、future、deprecated。" },
-  scoring_rule_id: { label: "评分细则 ID", desc: "评分细则 ID。" },
-  score_value: { label: "得分", desc: "命中该评分细则后给出的分数。" },
-  min_score: { label: "分数下限", desc: "分数下限。" },
-
-  // ===== effect_status_mapping =====
-  mapping_id: { label: "映射 ID", desc: "映射关系 ID。" },
-  target_level: { label: "映射层级", desc: "映射层级,例如 content 或 query。" },
-  reason_category: { label: "原因分类", desc: "原因分类。" },
-  is_hard_gate: { label: "硬规则触发", desc: "是否由 Hard Gate 触发。" },
-  content_effect_status: { label: "内容效果状态", desc: "内容级效果状态。" },
-  query_effect_status: { label: "query 效果状态", desc: "query 级效果状态。" },
-
-  // ===== decision_reason_codes =====
-  reason_label: { label: "原因中文名", desc: "原因码中文名称。" },
-  business_explanation: { label: "业务解释", desc: "原因码的业务解释。" },
-
-  // ===== walk_rule_pack_binding =====
-  binding_id: { label: "绑定 ID", desc: "边→规则包绑定 ID。" },
-  required: { label: "是否必须", desc: "是否必须通过该规则判断。" },
-  dispatch_policy: { label: "调用方式", desc: "调用方式,如 required / optional / advisory。" },
-
-  // ===== walk_edge_catalog =====
-  edge_type: { label: "边类型", desc: "边类型。" },
-  can_loop: { label: "可循环", desc: "是否允许循环或多轮。" },
-
-  // ===== walk_policy.edge_budgets =====
-  budget_id: { label: "预算规则 ID", desc: "预算规则 ID。" },
-  max_total_actions: { label: "单 run 上限", desc: "单 run 最多执行次数。" },
-  max_per_query: { label: "每 query 上限", desc: "每条 query 最多执行次数。" },
-  max_per_content: { label: "每内容上限", desc: "每条内容最多执行次数。" },
-  max_works_per_author: { label: "每作者作品上限", desc: "每个作者最多取多少条新作品。" },
-  max_pages: { label: "最大页数", desc: "最大页数。" },
-  max_depth: { label: "最大深度", desc: "最大游走深度。" },
-  max_tag_hops: { label: "tag 跳数上限", desc: "最大 tag 扩散跳数。" },
-  provenance: { label: "拍板来源", desc: "该值的拍板/取值来源说明(留痕)。" },
-  tbd: { label: "待定", desc: "该值是否仍待标定(true=未定稿)。" },
-
-  // ===== walk_policy.global / 配额闸 =====
-  gemini_calls_per_run_cap: { label: "Gemini 单 run 配额", desc: "单次 run 允许的 Gemini 判定调用上限,超出后剩余内容确定性截断(QuotaCapped)。" },
-  gemini_max_workers: { label: "判定并发数", desc: "Gemini 判定批的最大并发 worker 数。" },
-  max_total_actions_per_run: { label: "游走总动作上限", desc: "单 run 所有边动作合计上限(全局闸)。" },
-
-  // ===== query prompts(M2 brief 合同) =====
-  prompt_version: { label: "Prompt 版本", desc: "prompt 配置版本,decision 记录中的 prompt 真相源。" },
-  temperature: { label: "温度", desc: "LLM 采样温度,与 V1 行为逐字复刻(0.4)。" },
-  max_tokens: { label: "最大 token", desc: "LLM 输出 token 上限。" },
-  variants_per_seed: { label: "每种子变体数", desc: "每个种子词生成的 query 数;>1 暂不支持(M2 拍板,配置大于 1 直接报错)。" },
-  evidence_fields: { label: "证据字段", desc: "拼装 LLM 输入 evidence_context 时读取的字段清单(17+ 个,顺序固定)。" },
-  generic_filter: { label: "泛词过滤", desc: "生成结果命中泛词(如「内容」「热门」)时丢弃,避免无效搜索词。" }
-};
-
-export function fieldAnnotation(field: string): FieldAnnotation {
-  return FIELD_ANNOTATIONS[field] || { label: field, desc: "" };
-}

+ 0 - 68
web/lib/platform/content.ts

@@ -1,68 +0,0 @@
-// 多平台内容 URL / 嵌入播放器 / 平台名:按 item.platform 分派,替代 V2 抖音写死。
-
-type Item = Record<string, unknown>;
-
-function str(v: unknown): string | null {
-  if (typeof v === "string" && v.trim()) return v;
-  return null;
-}
-
-// 平台名静态兜底(与 platform_profiles 的 platform_label 对齐)。
-// 动态可由 /config/platforms 覆盖;此表保证 catalog 未加载时各处(列表卡、顶栏 chip)仍显示中文。
-const PLATFORM_LABELS: Record<string, string> = {
-  douyin: "抖音",
-  shipinhao: "视频号",
-  kuaishou: "快手",
-  xiaohongshu: "小红书",
-  bilibili: "B站",
-  weixin: "公众号",
-  toutiao: "头条",
-  zhihu: "知乎",
-  youtube: "YouTube",
-  github: "GitHub"
-};
-
-export function platformLabel(platform: unknown, catalog?: Record<string, { label: string }>): string {
-  const key = String(platform || "").toLowerCase();
-  return catalog?.[key]?.label || PLATFORM_LABELS[key] || (key ? key : "未知平台");
-}
-
-// 统一互动指标键 → 中文标签(后端各平台 normalizer 已把原生字段映射到这 5 个键)。
-export const HEAT_FIELD_LABELS: Record<string, string> = {
-  digg_count: "点赞",
-  comment_count: "评论",
-  share_count: "分享",
-  collect_count: "收藏",
-  play_count: "播放"
-};
-
-// 原帖链接:优先用记录里真实落库的 url;无则仅抖音可由 content_id 拼回,其余平台返回 null(不伪造)。
-export function contentUrl(item: Item): string | null {
-  const media = (item.media_record as Item) || {};
-  const direct =
-    str(item.platform_content_url) ||
-    str(item.share_url) ||
-    str(item.url) ||
-    str(item.aweme_url) ||
-    str(media.share_url) ||
-    str(item.play_url) || // 视频号无网页原帖,仅有视频流地址,兜底用它
-    str(media.play_url);
-  if (direct) return direct;
-  const platform = String(item.platform || "").toLowerCase();
-  const id = str(item.platform_content_id);
-  if (platform === "douyin" && id) {
-    return `https://www.douyin.com/video/${encodeURIComponent(id)}`;
-  }
-  return null;
-}
-
-// 可跨站 iframe 的官方嵌入播放器:仅抖音有(open.douyin.com 无 X-Frame-Options);
-// 视频号等无公开 embed → null,UI 降级为"打开原帖"。
-export function embedPlayerUrl(item: Item): string | null {
-  const platform = String(item.platform || "").toLowerCase();
-  const id = str(item.platform_content_id);
-  if (platform === "douyin" && id) {
-    return `https://open.douyin.com/player/video?vid=${encodeURIComponent(id)}&autoplay=1`;
-  }
-  return null;
-}

+ 0 - 40
web/lib/status/status.ts

@@ -1,40 +0,0 @@
-export function statusLabel(status: unknown) {
-  const value = String(status || "unknown");
-  const labels: Record<string, string> = {
-    success: "成功",
-    partial_success: "部分成功",
-    failed: "失败",
-    pending: "待复看",
-    rule_blocked: "规则阻断",
-    running: "运行中",
-    pass: "通过",
-    fail: "未通过",
-    skipped: "跳过",
-    // 阶段事件
-    stage_started: "阶段开始",
-    stage_completed: "阶段完成",
-    stage_failed: "阶段失败",
-    // V3 Gemini 判定状态(pattern_match_result.judge_status / run_events)
-    ok: "判定成功",
-    judged: "已判定",
-    gemini_quota_exhausted: "Gemini 配额截断",
-    judge_failed: "判定技术失败",
-    // 限流分类
-    PLATFORM_RATE_LIMITED: "平台限流",
-    PLATFORM_REQUEST_FAILED: "平台请求失败"
-  };
-  return labels[value] || value;
-}
-
-export function compactValue(value: unknown) {
-  if (value === null || value === undefined || value === "") {
-    return "缺失";
-  }
-  if (Array.isArray(value)) {
-    return value.length ? value.join(", ") : "空";
-  }
-  if (typeof value === "object") {
-    return JSON.stringify(value);
-  }
-  return String(value);
-}

+ 0 - 6
web/next-env.d.ts

@@ -1,6 +0,0 @@
-/// <reference types="next" />
-/// <reference types="next/image-types/global" />
-/// <reference path="./.next/types/routes.d.ts" />
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 0 - 7
web/next.config.ts

@@ -1,7 +0,0 @@
-import type { NextConfig } from "next";
-
-const nextConfig: NextConfig = {
-  reactStrictMode: true
-};
-
-export default nextConfig;

+ 0 - 1033
web/package-lock.json

@@ -1,1033 +0,0 @@
-{
-  "name": "content-find-agent-web",
-  "version": "0.1.0",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "name": "content-find-agent-web",
-      "version": "0.1.0",
-      "dependencies": {
-        "lucide-react": "^0.468.0",
-        "next": "^15.1.0",
-        "react": "^19.0.0",
-        "react-dom": "^19.0.0"
-      },
-      "devDependencies": {
-        "@types/node": "^22.10.0",
-        "@types/react": "^19.0.0",
-        "@types/react-dom": "^19.0.0",
-        "typescript": "^5.7.0"
-      }
-    },
-    "node_modules/@emnapi/runtime": {
-      "version": "1.11.0",
-      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
-      "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@img/colour": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
-      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
-      "license": "MIT",
-      "optional": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@img/sharp-darwin-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
-      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-darwin-arm64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-darwin-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
-      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-darwin-x64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-libvips-darwin-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
-      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-darwin-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
-      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-arm": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
-      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
-      "cpu": [
-        "arm"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
-      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
-      "cpu": [
-        "arm64"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-ppc64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
-      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-riscv64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
-      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-s390x": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
-      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
-      "cpu": [
-        "s390x"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
-      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
-      "cpu": [
-        "x64"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
-      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
-      "cpu": [
-        "arm64"
-      ],
-      "libc": [
-        "musl"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
-      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
-      "cpu": [
-        "x64"
-      ],
-      "libc": [
-        "musl"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-linux-arm": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
-      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
-      "cpu": [
-        "arm"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-arm": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
-      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
-      "cpu": [
-        "arm64"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-arm64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-ppc64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
-      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-ppc64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-riscv64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
-      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
-      "cpu": [
-        "riscv64"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-riscv64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-s390x": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
-      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
-      "cpu": [
-        "s390x"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-s390x": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
-      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
-      "cpu": [
-        "x64"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-x64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linuxmusl-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
-      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
-      "cpu": [
-        "arm64"
-      ],
-      "libc": [
-        "musl"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linuxmusl-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
-      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
-      "cpu": [
-        "x64"
-      ],
-      "libc": [
-        "musl"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-wasm32": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
-      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
-      "cpu": [
-        "wasm32"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
-      "optional": true,
-      "dependencies": {
-        "@emnapi/runtime": "^1.7.0"
-      },
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-win32-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
-      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-win32-ia32": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
-      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
-      "cpu": [
-        "ia32"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-win32-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
-      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@next/env": {
-      "version": "15.5.19",
-      "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz",
-      "integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==",
-      "license": "MIT"
-    },
-    "node_modules/@next/swc-darwin-arm64": {
-      "version": "15.5.19",
-      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz",
-      "integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-darwin-x64": {
-      "version": "15.5.19",
-      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz",
-      "integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-linux-arm64-gnu": {
-      "version": "15.5.19",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz",
-      "integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==",
-      "cpu": [
-        "arm64"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-linux-arm64-musl": {
-      "version": "15.5.19",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz",
-      "integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==",
-      "cpu": [
-        "arm64"
-      ],
-      "libc": [
-        "musl"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-linux-x64-gnu": {
-      "version": "15.5.19",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz",
-      "integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==",
-      "cpu": [
-        "x64"
-      ],
-      "libc": [
-        "glibc"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-linux-x64-musl": {
-      "version": "15.5.19",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz",
-      "integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==",
-      "cpu": [
-        "x64"
-      ],
-      "libc": [
-        "musl"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-win32-arm64-msvc": {
-      "version": "15.5.19",
-      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz",
-      "integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@next/swc-win32-x64-msvc": {
-      "version": "15.5.19",
-      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
-      "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/@swc/helpers": {
-      "version": "0.5.15",
-      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
-      "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "tslib": "^2.8.0"
-      }
-    },
-    "node_modules/@types/node": {
-      "version": "22.19.20",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz",
-      "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "undici-types": "~6.21.0"
-      }
-    },
-    "node_modules/@types/react": {
-      "version": "19.2.17",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
-      "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "csstype": "^3.2.2"
-      }
-    },
-    "node_modules/@types/react-dom": {
-      "version": "19.2.3",
-      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
-      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
-      "dev": true,
-      "license": "MIT",
-      "peerDependencies": {
-        "@types/react": "^19.2.0"
-      }
-    },
-    "node_modules/caniuse-lite": {
-      "version": "1.0.30001797",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
-      "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/browserslist"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "CC-BY-4.0"
-    },
-    "node_modules/client-only": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
-      "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
-      "license": "MIT"
-    },
-    "node_modules/csstype": {
-      "version": "3.2.3",
-      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
-      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/detect-libc": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
-      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
-      "license": "Apache-2.0",
-      "optional": true,
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/lucide-react": {
-      "version": "0.468.0",
-      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
-      "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
-      "license": "ISC",
-      "peerDependencies": {
-        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
-      }
-    },
-    "node_modules/nanoid": {
-      "version": "3.3.12",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
-      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "bin": {
-        "nanoid": "bin/nanoid.cjs"
-      },
-      "engines": {
-        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
-      }
-    },
-    "node_modules/next": {
-      "version": "15.5.19",
-      "resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz",
-      "integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==",
-      "license": "MIT",
-      "dependencies": {
-        "@next/env": "15.5.19",
-        "@swc/helpers": "0.5.15",
-        "caniuse-lite": "^1.0.30001579",
-        "postcss": "8.4.31",
-        "styled-jsx": "5.1.6"
-      },
-      "bin": {
-        "next": "dist/bin/next"
-      },
-      "engines": {
-        "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
-      },
-      "optionalDependencies": {
-        "@next/swc-darwin-arm64": "15.5.19",
-        "@next/swc-darwin-x64": "15.5.19",
-        "@next/swc-linux-arm64-gnu": "15.5.19",
-        "@next/swc-linux-arm64-musl": "15.5.19",
-        "@next/swc-linux-x64-gnu": "15.5.19",
-        "@next/swc-linux-x64-musl": "15.5.19",
-        "@next/swc-win32-arm64-msvc": "15.5.19",
-        "@next/swc-win32-x64-msvc": "15.5.19",
-        "sharp": "^0.34.3"
-      },
-      "peerDependencies": {
-        "@opentelemetry/api": "^1.1.0",
-        "@playwright/test": "^1.51.1",
-        "babel-plugin-react-compiler": "*",
-        "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
-        "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
-        "sass": "^1.3.0"
-      },
-      "peerDependenciesMeta": {
-        "@opentelemetry/api": {
-          "optional": true
-        },
-        "@playwright/test": {
-          "optional": true
-        },
-        "babel-plugin-react-compiler": {
-          "optional": true
-        },
-        "sass": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/picocolors": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
-      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
-      "license": "ISC"
-    },
-    "node_modules/postcss": {
-      "version": "8.4.31",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
-      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/postcss/"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/postcss"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "nanoid": "^3.3.6",
-        "picocolors": "^1.0.0",
-        "source-map-js": "^1.0.2"
-      },
-      "engines": {
-        "node": "^10 || ^12 || >=14"
-      }
-    },
-    "node_modules/react": {
-      "version": "19.2.7",
-      "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
-      "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/react-dom": {
-      "version": "19.2.7",
-      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
-      "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
-      "license": "MIT",
-      "dependencies": {
-        "scheduler": "^0.27.0"
-      },
-      "peerDependencies": {
-        "react": "^19.2.7"
-      }
-    },
-    "node_modules/scheduler": {
-      "version": "0.27.0",
-      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
-      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
-      "license": "MIT"
-    },
-    "node_modules/semver": {
-      "version": "7.8.2",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
-      "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
-      "license": "ISC",
-      "optional": true,
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/sharp": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
-      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
-      "hasInstallScript": true,
-      "license": "Apache-2.0",
-      "optional": true,
-      "dependencies": {
-        "@img/colour": "^1.0.0",
-        "detect-libc": "^2.1.2",
-        "semver": "^7.7.3"
-      },
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-darwin-arm64": "0.34.5",
-        "@img/sharp-darwin-x64": "0.34.5",
-        "@img/sharp-libvips-darwin-arm64": "1.2.4",
-        "@img/sharp-libvips-darwin-x64": "1.2.4",
-        "@img/sharp-libvips-linux-arm": "1.2.4",
-        "@img/sharp-libvips-linux-arm64": "1.2.4",
-        "@img/sharp-libvips-linux-ppc64": "1.2.4",
-        "@img/sharp-libvips-linux-riscv64": "1.2.4",
-        "@img/sharp-libvips-linux-s390x": "1.2.4",
-        "@img/sharp-libvips-linux-x64": "1.2.4",
-        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
-        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
-        "@img/sharp-linux-arm": "0.34.5",
-        "@img/sharp-linux-arm64": "0.34.5",
-        "@img/sharp-linux-ppc64": "0.34.5",
-        "@img/sharp-linux-riscv64": "0.34.5",
-        "@img/sharp-linux-s390x": "0.34.5",
-        "@img/sharp-linux-x64": "0.34.5",
-        "@img/sharp-linuxmusl-arm64": "0.34.5",
-        "@img/sharp-linuxmusl-x64": "0.34.5",
-        "@img/sharp-wasm32": "0.34.5",
-        "@img/sharp-win32-arm64": "0.34.5",
-        "@img/sharp-win32-ia32": "0.34.5",
-        "@img/sharp-win32-x64": "0.34.5"
-      }
-    },
-    "node_modules/source-map-js": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
-      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
-      "license": "BSD-3-Clause",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/styled-jsx": {
-      "version": "5.1.6",
-      "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
-      "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
-      "license": "MIT",
-      "dependencies": {
-        "client-only": "0.0.1"
-      },
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "peerDependencies": {
-        "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
-      },
-      "peerDependenciesMeta": {
-        "@babel/core": {
-          "optional": true
-        },
-        "babel-plugin-macros": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/tslib": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "license": "0BSD"
-    },
-    "node_modules/typescript": {
-      "version": "5.9.3",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
-      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "bin": {
-        "tsc": "bin/tsc",
-        "tsserver": "bin/tsserver"
-      },
-      "engines": {
-        "node": ">=14.17"
-      }
-    },
-    "node_modules/undici-types": {
-      "version": "6.21.0",
-      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
-      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
-      "dev": true,
-      "license": "MIT"
-    }
-  }
-}

+ 0 - 22
web/package.json

@@ -1,22 +0,0 @@
-{
-  "name": "content-find-agent-web",
-  "version": "0.1.0",
-  "private": true,
-  "scripts": {
-    "dev": "next dev --hostname 127.0.0.1",
-    "lint": "tsc --noEmit",
-    "build": "next build"
-  },
-  "dependencies": {
-    "lucide-react": "^0.468.0",
-    "next": "^15.1.0",
-    "react": "^19.0.0",
-    "react-dom": "^19.0.0"
-  },
-  "devDependencies": {
-    "@types/node": "^22.10.0",
-    "@types/react": "^19.0.0",
-    "@types/react-dom": "^19.0.0",
-    "typescript": "^5.7.0"
-  }
-}

+ 0 - 23
web/tsconfig.json

@@ -1,23 +0,0 @@
-{
-  "compilerOptions": {
-    "target": "ES2017",
-    "lib": ["dom", "dom.iterable", "esnext"],
-    "allowJs": false,
-    "skipLibCheck": true,
-    "strict": true,
-    "noEmit": true,
-    "esModuleInterop": true,
-    "module": "esnext",
-    "moduleResolution": "bundler",
-    "resolveJsonModule": true,
-    "isolatedModules": true,
-    "jsx": "preserve",
-    "incremental": true,
-    "plugins": [{ "name": "next" }],
-    "paths": {
-      "@/*": ["./*"]
-    }
-  },
-  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
-  "exclude": ["node_modules"]
-}