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

feat(web): Next.js run dashboard frontend (runs list page)

- Next.js 15 + React 19 + TypeScript 骨架(app router)
- /runs 列表页:状态过滤、run_id/policy_run_id/error_code 搜索、
  data_origin 徽标、分页;AppShell 布局与 StatusBadge 组件
- lib/api:fetch 封装与响应类型,API base 走 NEXT_PUBLIC_CONTENTFIND_API_BASE_URL

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sam Lee 3 дней назад
Родитель
Сommit
c6c5e39

+ 4 - 0
web/.gitignore

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

+ 1184 - 0
web/app/globals.css

@@ -0,0 +1,1184 @@
+: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: 14px 20px 24px;
+}
+
+.topbar {
+  display: grid;
+  grid-template-columns: minmax(230px, 1fr) auto minmax(230px, 1fr);
+  align-items: center;
+  gap: 16px;
+  min-height: 62px;
+  margin-bottom: 10px;
+}
+
+.topbar-left {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+}
+
+.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: 20px;
+  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;
+}
+
+.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;
+}
+
+.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%;
+  }
+}

+ 19 - 0
web/app/layout.tsx

@@ -0,0 +1,19 @@
+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>
+  );
+}

+ 5 - 0
web/app/page.tsx

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

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

@@ -0,0 +1,10 @@
+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} />;
+}

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

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

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

@@ -0,0 +1,29 @@
+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>
+  );
+}

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

@@ -0,0 +1,14 @@
+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>
+  );
+}

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

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

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

@@ -0,0 +1,31 @@
+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>
+  );
+}

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

@@ -0,0 +1,47 @@
+import Link from "next/link";
+import { Activity, ArrowLeft, RefreshCw } from "lucide-react";
+
+export function AppShell({
+  children,
+  onRefresh,
+  showBack,
+  toolbarLeading
+}: {
+  children: React.ReactNode;
+  onRefresh?: () => void;
+  showBack?: boolean;
+  toolbarLeading?: React.ReactNode;
+}) {
+  return (
+    <main className="app-shell">
+      <div className="workspace">
+        <header className="topbar">
+          <div className="topbar-left">
+            {showBack ? (
+              <Link className="back-link" href="/runs">
+                <ArrowLeft size={15} />
+                返回 Dashboard
+              </Link>
+            ) : null}
+          </div>
+          <Link className="brand" href="/runs">
+            <strong>V1 可视化工作台</strong>
+          </Link>
+          <div className="toolbar">
+            {toolbarLeading}
+            <span className="badge">
+              <Activity size={13} />
+              Douyin V1
+            </span>
+            {onRefresh ? (
+              <button className="icon-button" onClick={onRefresh} type="button" title="刷新">
+                <RefreshCw size={16} />
+              </button>
+            ) : null}
+          </div>
+        </header>
+        {children}
+      </div>
+    </main>
+  );
+}

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

@@ -0,0 +1,29 @@
+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>
+  );
+}

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

@@ -0,0 +1,942 @@
+"use client";
+
+import {
+  ChevronRight,
+  FileJson,
+  GitBranch,
+  ListFilter,
+  PanelRightOpen,
+  ShieldCheck,
+  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";
+
+type DashboardData = {
+  dashboard: DashboardResponse;
+  queries: QueryListResponse;
+  contentItems: ContentItemsResponse;
+  timeline: TimelineResponse;
+  runtimeFiles: RuntimeFilesResponse;
+  sourceContext: RuntimeFileResponse | null;
+  patternSeed: RuntimeFileResponse | null;
+};
+
+type DrawerContent =
+  | { kind: "technical"; title: string; payload: unknown }
+  | { kind: "rule"; title: string; payload: unknown }
+  | { kind: "walk"; title: string; payload: unknown }
+  | null;
+
+async function optionalRuntimeFile(runId: string, filename: string): Promise<RuntimeFileResponse | null> {
+  try {
+    return await getRuntimeFile(runId, filename, 80);
+  } catch {
+    return null;
+  }
+}
+
+export function RunDashboardPage({ runId }: { runId: string }) {
+  const [activeStage, setActiveStage] = useState("source");
+  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, sourceContext, patternSeed] = await Promise.all([
+        getDashboard(runId),
+        getQueries(runId),
+        getContentItems(runId),
+        getTimeline(runId),
+        getRuntimeFiles(runId),
+        optionalRuntimeFile(runId, "source_context.json"),
+        optionalRuntimeFile(runId, "pattern_seed_pack.json")
+      ]);
+      setData({ dashboard, queries, contentItems, timeline, runtimeFiles, sourceContext, patternSeed });
+    } 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
+      toolbarLeading={
+        data ? (
+          <button
+            className="text-button"
+            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} />
+            本次 run metadata
+          </button>
+        ) : null
+      }
+    >
+      {loading ? <div className="loading-state">加载中</div> : null}
+      {error ? <div className="error-state">{error}</div> : null}
+
+      {data ? (
+        <section className="detail-panel business-page">
+          <StageNavigationCards
+            dashboard={data.dashboard}
+            stages={data.dashboard.stage_conclusions}
+            activeStage={activeStage}
+            onSelect={setActiveStage}
+          />
+          <StagePanel
+            activeStage={activeStage}
+            data={data}
+            runtimeFile={runtimeFile}
+            onOpenRuntimeFile={openRuntimeFile}
+            onOpenDrawer={setDrawer}
+          />
+        </section>
+      ) : null}
+
+      <TechnicalDetailsDrawer drawer={drawer} onClose={() => setDrawer(null)} />
+    </AppShell>
+  );
+}
+
+function StageNavigationCards({
+  dashboard,
+  stages,
+  activeStage,
+  onSelect
+}: {
+  dashboard: DashboardResponse;
+  stages: StageConclusion[];
+  activeStage: string;
+  onSelect: (stageId: string) => void;
+}) {
+  return (
+    <nav className="stage-nav-grid" aria-label="运行阶段">
+      {stages.map((stage) => (
+        <button
+          className={`stage-nav-card ${stage.status} ${activeStage === stage.stage_id ? "active" : ""}`}
+          key={stage.stage_id}
+          onClick={() => onSelect(stage.stage_id)}
+          type="button"
+        >
+          <div className="stage-nav-top">
+            <strong>{stage.label}</strong>
+            {stageCount(dashboard, stage.stage_id) !== null ? (
+              <span className="stage-nav-count">{stageCount(dashboard, stage.stage_id)}</span>
+            ) : null}
+          </div>
+          <div className="stage-nav-body">
+            <span>{stage.headline}</span>
+            <small>{stage.metric}</small>
+          </div>
+        </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.asset_count ?? 0;
+  return null;
+}
+
+function queryStageStatus(query: Record<string, unknown>): { status: string; label: string } {
+  if (query.failure_reason) {
+    return { status: "failed", label: "平台失败" };
+  }
+  return { status: "success", label: "生成成功" };
+}
+
+function queryGenerationMethodLabel(method: unknown): string {
+  const value = String(method || "");
+  const labels: Record<string, string> = {
+    item_single: "原词直搜",
+    llm_variant: "AI 扩写",
+    query_next_page: "翻页搜索",
+    tag_query: "Tag 搜索"
+  };
+  return labels[value] || compactValue(method);
+}
+
+function queryGenerationExplanation(query: Record<string, unknown>): Record<string, 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>
+    : {};
+  const method = String(query.search_query_generation_method || "");
+  const promptText =
+    method === "llm_variant"
+      ? "AI 扩写:基于 seed_term、分类路径、元素证据和已有 query,生成一条更适合抖音搜索的变体 query。"
+      : method === "item_single"
+        ? "原词直搜:不调用 LLM,直接把 DemandAgent seed_term 作为搜索词。"
+        : method === "query_next_page"
+          ? "翻页搜索:不调用 LLM,沿用上一页 query 和 next_cursor 拉取下一页。"
+          : "按当前生成方式生成搜索词。";
+  return {
+    "生成出的 query": query.search_query,
+    "生成方式": queryGenerationMethodLabel(method),
+    "种子词": seedRef.seed_term || llmInput.seed_term || query.search_query,
+    "父 query": query.llm_variant_of || query.parent_search_query_id || "无",
+    "提示词口径": promptText,
+    "提示词版本": query.llm_prompt_version || "不适用",
+    "LLM 模型": query.llm_generation_model || "不适用",
+    "输入证据摘要": {
+      seed_terms: llmInput.seed_terms || seedRef.seed_terms || seedRef.seed_term,
+      itemset_ids: llmInput.itemset_ids || seedRef.itemset_ids,
+      category_bindings: llmInput.category_bindings,
+      element_bindings: llmInput.element_bindings,
+      source_post_id: llmInput.source_post_id || seedRef.source_post_id,
+      pattern_execution_id: llmInput.pattern_execution_id || seedRef.pattern_execution_id,
+      existing_search_queries: llmInput.existing_search_queries
+    },
+    "原始记录": query
+  };
+}
+
+function isLlmVariantQuery(query: Record<string, unknown>): boolean {
+  return query.search_query_generation_method === "llm_variant";
+}
+
+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;
+}
+
+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
+    }
+  };
+}
+
+function primaryQuerySource(item: Record<string, unknown>): Record<string, unknown> {
+  const sources = Array.isArray(item.query_sources) ? item.query_sources : [];
+  const first = sources[0];
+  if (first && typeof first === "object") {
+    return first as Record<string, unknown>;
+  }
+  return {
+    search_query_id: item.search_query_id,
+    search_query: item.search_query,
+    search_query_generation_method: item.search_query_generation_method
+  };
+}
+
+function douyinContentUrl(item: Record<string, unknown>): string {
+  const direct = item.share_url || item.url || item.aweme_url;
+  if (direct) {
+    return String(direct);
+  }
+  return `https://www.douyin.com/video/${encodeURIComponent(String(item.platform_content_id || ""))}`;
+}
+
+function runtimeData(file: RuntimeFileResponse | null): Record<string, unknown> {
+  if (!file?.data || typeof file.data !== "object") {
+    return {};
+  }
+  return file.data as Record<string, unknown>;
+}
+
+function evidencePackFrom(sourceContext: Record<string, unknown>): Record<string, unknown> {
+  const extData = sourceContext.ext_data;
+  if (extData && typeof extData === "object" && "evidence_pack" in extData) {
+    const evidencePack = (extData as Record<string, unknown>).evidence_pack;
+    return evidencePack && typeof evidencePack === "object" ? evidencePack as Record<string, unknown> : {};
+  }
+  return {};
+}
+
+function firstCategoryPath(patternSeed: Record<string, unknown>): string {
+  const bindings = Array.isArray(patternSeed.category_bindings) ? patternSeed.category_bindings : [];
+  const first = bindings[0];
+  if (first && typeof first === "object") {
+    return compactValue((first as Record<string, unknown>).category_path || (first as Record<string, unknown>).category_full_path);
+  }
+  const itemsets = Array.isArray(patternSeed.itemsets) ? patternSeed.itemsets : [];
+  const firstItemset = itemsets[0];
+  if (firstItemset && typeof firstItemset === "object") {
+    return compactValue((firstItemset as Record<string, unknown>).category_path);
+  }
+  return "缺失";
+}
+
+function SourceEvidenceSummary({
+  sourceContext,
+  patternSeed
+}: {
+  sourceContext: RuntimeFileResponse | null;
+  patternSeed: RuntimeFileResponse | null;
+}) {
+  const source = runtimeData(sourceContext);
+  const seed = runtimeData(patternSeed);
+  const evidencePack = evidencePackFrom(source);
+  const extData = source.ext_data && typeof source.ext_data === "object"
+    ? source.ext_data as Record<string, unknown>
+    : {};
+  const seedTerms = Array.isArray(seed.seed_terms) ? seed.seed_terms : evidencePack.seed_terms;
+  const matchedPostIds = Array.isArray(seed.matched_post_ids) ? seed.matched_post_ids : evidencePack.matched_post_ids;
+  return (
+    <div className="source-summary-grid">
+      <div>
+        <span>需求名称</span>
+        <strong>{compactValue(source.name || extData.type)}</strong>
+        <small>{compactValue(extData.desc || extData.reason)}</small>
+      </div>
+      <div>
+        <span>Pattern 来源</span>
+        <strong>{compactValue(seed.pattern_source_system || evidencePack.pattern_source_system)}</strong>
+        <small>Pattern 执行 ID:{compactValue(seed.pattern_execution_id || evidencePack.pattern_execution_id)}</small>
+      </div>
+      <div>
+        <span>种子词</span>
+        <strong>{compactValue(seedTerms)}</strong>
+        <small>Itemset:{compactValue(seed.itemset_ids || evidencePack.itemset_ids)}</small>
+      </div>
+      <div>
+        <span>Pattern 分类路径</span>
+        <strong>{firstCategoryPath(seed)}</strong>
+        <small>来源样本:{compactValue(seed.source_post_id || evidencePack.source_post_id)}</small>
+      </div>
+      <div>
+        <span>证据规模</span>
+        <strong>{Array.isArray(matchedPostIds) ? `${matchedPostIds.length} 条匹配样本` : "缺失"}</strong>
+        <small>验证状态:{compactValue(seed.validation_status || evidencePack.validation_status || source.validation_status)}</small>
+      </div>
+    </div>
+  );
+}
+
+function StagePanel({
+  activeStage,
+  data,
+  runtimeFile,
+  onOpenRuntimeFile,
+  onOpenDrawer
+}: {
+  activeStage: string;
+  data: DashboardData;
+  runtimeFile: RuntimeFileResponse | null;
+  onOpenRuntimeFile: (filename: string) => void;
+  onOpenDrawer: (drawer: DrawerContent) => void;
+}) {
+  if (activeStage === "source") {
+    return (
+      <BusinessSection title="数据源结论" icon={<Target size={17} />}>
+        <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "source")} />
+        <SourceEvidenceSummary sourceContext={data.sourceContext} patternSeed={data.patternSeed} />
+        <div className="business-action-row">
+          <button className="text-button" onClick={() => onOpenRuntimeFile("source_context.json")} type="button">
+            <FileJson size={15} />
+            查看需求证据
+          </button>
+          <button className="text-button" onClick={() => onOpenRuntimeFile("pattern_seed_pack.json")} type="button">
+            <FileJson size={15} />
+            查看 Pattern 种子
+          </button>
+        </div>
+      </BusinessSection>
+    );
+  }
+  if (activeStage === "query") {
+    return (
+      <BusinessSection title="Query 效果" icon={<ListFilter size={17} />}>
+        <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "query")} />
+        <div className="business-card-list">
+          {data.queries.items.length ? data.queries.items.map((query, index) => (
+            <div className="business-record-card" key={String(query.search_query_id || index)}>
+              <strong>{compactValue(query.search_query)}</strong>
+              <div className="badge-row">
+                <span className="badge">{queryGenerationMethodLabel(query.search_query_generation_method)}</span>
+                <span className={`badge ${queryStageStatus(query).status}`}>
+                  {queryStageStatus(query).label}
+                </span>
+              </div>
+              <div className="query-meta-line">
+                <span>种子词:{compactValue((query.pattern_seed_ref as Record<string, unknown> | undefined)?.seed_term)}</span>
+                <span>Query ID:{compactValue(query.search_query_id)}</span>
+              </div>
+              <span>{compactValue(query.failure_reason || "已生成,可在判断模块查看内容结果")}</span>
+              <div className="business-action-row">
+                <button
+                  className="text-button"
+                  onClick={() =>
+                    onOpenDrawer({
+                      kind: "technical",
+                      title: `Query 生成依据:${compactValue(query.search_query)}`,
+                      payload: queryGenerationExplanation(query)
+                    })
+                  }
+                  type="button"
+                >
+                  查看生成依据
+                </button>
+                {isLlmVariantQuery(query) ? (
+                  <button
+                    className="text-button"
+                    onClick={() =>
+                      onOpenDrawer({
+                        kind: "technical",
+                        title: `AI 扩写提示词:${compactValue(query.search_query)}`,
+                        payload: llmVariantPromptPayload(query)
+                      })
+                    }
+                    type="button"
+                  >
+                    查看 AI 扩写提示词
+                  </button>
+                ) : null}
+              </div>
+            </div>
+          )) : <div className="empty-state">没有 query 记录</div>}
+        </div>
+      </BusinessSection>
+    );
+  }
+  if (activeStage === "platform") {
+    return (
+      <BusinessSection title="平台发现内容" icon={<Target size={17} />}>
+        <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "platform")} />
+        <div className="business-card-list">
+          {data.contentItems.items.length ? data.contentItems.items.map((item, index) => (
+            <div className="business-record-card" key={String(item.platform_content_id || index)}>
+              <div className="content-card-heading">
+                <span>平台内容 #{index + 1}</span>
+                <strong>{compactValue(item.title || item.description || "抖音视频")}</strong>
+              </div>
+              <div className="content-meta-grid">
+                <span>
+                  抖音视频 ID
+                  <strong>{compactValue(item.platform_content_id)}</strong>
+                </span>
+                <span>
+                  来源 Query
+                  <strong>{compactValue(primaryQuerySource(item).search_query_id)}</strong>
+                </span>
+                <span>
+                  搜索关键词
+                  <strong>{compactValue(primaryQuerySource(item).search_query)}</strong>
+                </span>
+                <span>
+                  Query 类型
+                  <strong>{queryGenerationMethodLabel(primaryQuerySource(item).search_query_generation_method)}</strong>
+                </span>
+                <span>
+                  抖音作者
+                  <strong>{compactValue(item.author_display_name || item.platform_author_id)}</strong>
+                </span>
+              </div>
+              <div className="business-action-row">
+                <a
+                  className="text-button"
+                  href={douyinContentUrl(item)}
+                  rel="noreferrer"
+                  target="_blank"
+                >
+                  打开原帖
+                </a>
+              </div>
+            </div>
+          )) : <div className="empty-state">还没有发现内容;请先查看 Query 或平台失败原因</div>}
+        </div>
+      </BusinessSection>
+    );
+  }
+  if (activeStage === "judge") {
+    return (
+      <BusinessSection title="策略包如何施加到内容判断" icon={<ShieldCheck size={17} />}>
+        <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "judge")} />
+        <div className="rule-chain">
+          <span>内容</span>
+          <ChevronRight size={16} />
+          <span>EvidenceBundle</span>
+          <ChevronRight size={16} />
+          <span>Content Rule Pack V1</span>
+          <ChevronRight size={16} />
+          <span>硬性筛选门槛</span>
+          <ChevronRight size={16} />
+          <span>Scorecard</span>
+          <ChevronRight size={16} />
+          <span>Decision</span>
+        </div>
+        <div className="business-card-list">
+          {data.dashboard.rule_application_summary.length ? data.dashboard.rule_application_summary.map((item, index) => (
+            <RuleApplicationCard
+              item={item}
+              key={item.decision_id || index}
+              onOpen={() =>
+                onOpenDrawer({
+                  kind: "rule",
+                  title: item.content_title || item.platform_content_id || "规则详情",
+                  payload: item
+                })
+              }
+            />
+          )) : <div className="empty-state">还没有内容进入规则判断</div>}
+        </div>
+      </BusinessSection>
+    );
+  }
+  if (activeStage === "walk") {
+    return (
+      <BusinessSection title="游走路径图" icon={<GitBranch size={17} />}>
+        <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "walk")} />
+        <WalkGraphCanvas
+          graph={data.dashboard.walk_graph}
+          onOpen={(payload) =>
+            onOpenDrawer({
+              kind: "walk",
+              title: String(payload.label || 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")} />
+        <div className="business-card-list">
+          <div className="business-record-card">
+            <strong>内容资产</strong>
+            <span>入池:{compactValue(data.dashboard.business_summary.kept_count)}</span>
+            <span>待复看:{compactValue(data.dashboard.business_summary.review_count)}</span>
+            <span>淘汰:{compactValue(data.dashboard.business_summary.rejected_count)}</span>
+          </div>
+        </div>
+      </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>
+    );
+  }
+  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 RuleApplicationCard({
+  item,
+  onOpen
+}: {
+  item: RuleApplicationSummary;
+  onOpen: () => void;
+}) {
+  const title = item.content_title && item.content_title !== item.platform_content_id ? item.content_title : "视频内容";
+  return (
+    <div className="rule-application-card">
+      <div className="rule-card-heading">
+        <span>判断对象</span>
+        <strong>{compactValue(title)}</strong>
+        <small>抖音视频 ID:{compactValue(item.platform_content_id)}</small>
+      </div>
+      <div className="rule-application-flow">
+        <span>
+          规则包:{rulePackLabel(item.rule_pack)}
+          <small>规则包 ID:{compactValue(item.rule_pack)}</small>
+        </span>
+        <span>
+          硬性筛选门槛:{hardGateLabel(item)}
+          <small>{reasonLabel(item.decision_reason_code)}</small>
+        </span>
+        <span>
+          Score:{scoreLabel(item.score)}
+          <small>{item.score === null || item.score === undefined ? "未进入打分或分数缺失" : "内容综合分"}</small>
+        </span>
+        <span>
+          判断结果:{decisionActionLabel(item.decision_action)}
+          <small>{effectStatusLabel(item.content_effect_status)}</small>
+        </span>
+      </div>
+      <div className="business-alert compact">
+        <ShieldCheck size={14} />
+        <span>主原因:{reasonLabel(item.decision_reason_code || item.primary_reason)}</span>
+      </div>
+      <button className="text-button" onClick={onOpen} type="button">
+        查看规则包详情
+      </button>
+    </div>
+  );
+}
+
+function rulePackLabel(rulePack: unknown): string {
+  const value = String(rulePack || "");
+  const labels: Record<string, string> = {
+    douyin_content_discovery_rule_pack_v1: "内容发现规则包 V1(抖音)",
+    Content_Rule_Pack_V1: "内容发现规则包 V1",
+    "Content Rule Pack V1": "内容发现规则包 V1"
+  };
+  return labels[value] || compactValue(rulePack || "内容发现规则包 V1");
+}
+
+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: "安全风险高:内容命中高风险判断"
+  };
+  return labels[value] || compactValue(reason);
+}
+
+function WalkGraphCanvas({
+  graph,
+  onOpen
+}: {
+  graph: DashboardResponse["walk_graph"];
+  onOpen: (payload: Record<string, unknown>) => void;
+}) {
+  const { nodes, edges } = useMemo(() => toBoardGraph(graph), [graph]);
+  if (!nodes.length || nodes.length === 1 && !edges.length) {
+    return (
+      <div className="walk-empty-board">
+        <GitBranch size={28} />
+        <strong>当前 run 没有可执行游走路径</strong>
+        <span>如果 query 或平台阶段失败,游走图会保持为空;这不是伪造缺口。</span>
+      </div>
+    );
+  }
+  return (
+    <div className="walk-board">
+      <div className="walk-board-inner">
+        <svg className="walk-lines" aria-hidden="true">
+          {edges.map((edge) => (
+            <g key={edge.id}>
+              <line
+                x1={edge.sourcePoint.x}
+                y1={edge.sourcePoint.y}
+                x2={edge.targetPoint.x}
+                y2={edge.targetPoint.y}
+              />
+              <text
+                x={(edge.sourcePoint.x + edge.targetPoint.x) / 2}
+                y={(edge.sourcePoint.y + edge.targetPoint.y) / 2 - 6}
+              >
+                {edge.label}
+              </text>
+            </g>
+          ))}
+        </svg>
+        {nodes.map((node) => (
+          <button
+            className={`walk-node-card ${node.status}`}
+            key={node.id}
+            onClick={() => onOpen(node.payload)}
+            style={{ left: node.x, top: node.y }}
+            type="button"
+          >
+            <strong>{node.label}</strong>
+            <span>{node.type} · {node.status}</span>
+          </button>
+        ))}
+        {edges.map((edge) => (
+          <button
+            className={`walk-edge-chip ${edge.status || "pending"}`}
+            key={`${edge.id}-chip`}
+            onClick={() => onOpen(edge.payload)}
+            style={{
+              left: (edge.sourcePoint.x + edge.targetPoint.x) / 2 - 70,
+              top: (edge.sourcePoint.y + edge.targetPoint.y) / 2 + 8
+            }}
+            type="button"
+          >
+            {edge.label}
+          </button>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+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>
+  );
+}
+
+function toBoardGraph(graph: DashboardResponse["walk_graph"]) {
+  const nodes = graph.nodes.map((node, index) => {
+    const col = index % 4;
+    const row = Math.floor(index / 4);
+    return {
+      ...node,
+      x: 38 + col * 230,
+      y: 48 + row * 120,
+      payload: node as unknown as Record<string, unknown>
+    };
+  });
+  const nodeById = new Map(nodes.map((node) => [node.id, node]));
+  const edges = graph.edges
+    .map((edge) => {
+      const source = nodeById.get(edge.source);
+      const target = nodeById.get(edge.target);
+      if (!source || !target) {
+        return null;
+      }
+      return {
+        ...edge,
+        label: edge.label || edge.status || "edge",
+        sourcePoint: { x: source.x + 155, y: source.y + 36 },
+        targetPoint: { x: target.x, y: target.y + 36 },
+        payload: edge as unknown as Record<string, unknown>
+      };
+    })
+    .filter((edge): edge is NonNullable<typeof edge> => Boolean(edge));
+  return { nodes, edges };
+}

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

@@ -0,0 +1,141 @@
+"use client";
+
+import Link from "next/link";
+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 type { RunListItem, RunListResponse } from "@/lib/api/types";
+
+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 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(() => {
+    const source = data?.items || [];
+    if (!search.trim()) {
+      return source;
+    }
+    const keyword = search.trim().toLowerCase();
+    return 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))
+    );
+  }, [data, search]);
+
+  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} key={item.run_id} />)
+            : null}
+        </div>
+        <div className="detail-panel">
+          <div className="detail-header">
+            <div className="detail-title">
+              <span>Web Dashboard</span>
+              <strong>选择一个 run 查看完整链路</strong>
+            </div>
+          </div>
+          <div className="metrics-grid">
+            <div className="metric">
+              <span>Total</span>
+              <strong>{data?.total ?? 0}</strong>
+            </div>
+            <div className="metric">
+              <span>Shown</span>
+              <strong>{items.length}</strong>
+            </div>
+          </div>
+          <div className="section">
+            <div className="section-title">
+              <strong>数据边界</strong>
+            </div>
+            <p className="muted">
+              页面只展示后端返回的真实 run 数据;如果 DB 或 runtime 缺少完整链路,这里会保持空状态或失败诊断。
+            </p>
+          </div>
+        </div>
+      </section>
+    </AppShell>
+  );
+}
+
+function RunCard({ item }: { item: RunListItem }) {
+  return (
+    <Link className="run-card" href={`/runs/${encodeURIComponent(item.run_id)}`}>
+      <div className="run-card-title">
+        <span>{item.run_id}</span>
+        <StatusBadge status={item.status} />
+      </div>
+      <div className="run-card-meta">
+        <span>{item.platform || "unknown"}</span>
+        <span>{item.platform_mode || "unknown"}</span>
+        <span>{item.strategy_version || "unknown"}</span>
+      </div>
+      <div className="run-card-meta">
+        <span>{item.started_at || "no started_at"}</span>
+        {item.error_code ? <span>{item.error_code}</span> : null}
+      </div>
+    </Link>
+  );
+}

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

@@ -0,0 +1,54 @@
+import type { DashboardResponse } from "@/lib/api/types";
+
+export type PipelineStage = {
+  id: string;
+  label: string;
+  status: "active" | "warn" | "plain";
+  count?: number;
+};
+
+export function pipelineStages(dashboard?: DashboardResponse): PipelineStage[] {
+  const counts = dashboard?.counts || {};
+  const files = dashboard?.files || {};
+  return [
+    {
+      id: "source",
+      label: "数据源",
+      status: files["source_context.json"] ? "active" : "warn"
+    },
+    {
+      id: "query",
+      label: "Query",
+      status: counts.queries ? "active" : "warn",
+      count: counts.queries
+    },
+    {
+      id: "platform",
+      label: "平台 / 内容",
+      status: counts.discovered_content_items ? "active" : "warn",
+      count: counts.discovered_content_items
+    },
+    {
+      id: "judge",
+      label: "判断",
+      status: counts.rule_decisions ? "active" : "warn",
+      count: counts.rule_decisions
+    },
+    {
+      id: "walk",
+      label: "游走",
+      status: counts.walk_actions ? "active" : "plain",
+      count: counts.walk_actions
+    },
+    {
+      id: "asset",
+      label: "资产沉淀",
+      status: files["final_output.json"] ? "active" : "plain"
+    },
+    {
+      id: "learning",
+      label: "策略学习",
+      status: files["strategy_review.json"] ? "active" : "plain"
+    }
+  ];
+}

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

@@ -0,0 +1,80 @@
+import type {
+  ContentItemsResponse,
+  DashboardResponse,
+  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}`
+  );
+}

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

@@ -0,0 +1,149 @@
+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;
+  strategy_version?: 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;
+  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 TimelineResponse = {
+  run_id: string;
+  items: Array<Record<string, unknown>>;
+  total: number;
+  data_origin: DataOrigin;
+};
+
+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;
+};

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

@@ -0,0 +1,27 @@
+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: "未通过"
+  };
+  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);
+}

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

@@ -0,0 +1,6 @@
+/// <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.

+ 7 - 0
web/next.config.ts

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

+ 1033 - 0
web/package-lock.json

@@ -0,0 +1,1033 @@
+{
+  "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"
+    }
+  }
+}

+ 22 - 0
web/package.json

@@ -0,0 +1,22 @@
+{
+  "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"
+  }
+}

+ 23 - 0
web/tsconfig.json

@@ -0,0 +1,23 @@
+{
+  "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"]
+}