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

feat(web): V2 timeline/log page, read-only config page, V1 bug fixes

- 时间线页 /runs/[id]/timeline:summary 七字段卡片、12 阶段耗时横条
  (stage_failed 红色高亮+error_code)、事件流日志线(来源过滤+raw_payload 展开);
  dashboard 加 TimelineSummaryStrip 与入口(消灭 timeline 黑洞接口)
- 配置只读页 /config:规则包(dispatch/hard gates/scorecard/映射/原因码)、
  游走策略(binding 8 条/边目录/预算/停止)、Query Prompts 三 tab;只读,
  编辑仍走 Excel→JSON 工具链
- dashboard walk 边展示归属包 vs 执行包(executed=false 标"已归属未运行")
- V1 bug 修复:pipeline 阶段状态改用 stage_conclusions(失败可见);
  status.ts 补 stage_*/decode 五态/限流等映射;optionalRuntimeFile 不再静默吞错;
  rulePackLabel 补 future 包并取消误导性兜底;列表页展示 validation_status
- types.ts 补 TimelineSummary/TimelineItem/ConfigFileResponse 强类型

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

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

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

+ 120 - 0
web/app/globals.css

@@ -1182,3 +1182,123 @@ a {
     width: 100%;
   }
 }
+
+/* ===== Web V2: 时间线 / 日志 / 配置页 ===== */
+.chip {
+  border: 1px solid var(--line, #d8dce5);
+  background: transparent;
+  border-radius: 999px;
+  padding: 4px 12px;
+  font-size: 12px;
+  cursor: pointer;
+  color: inherit;
+}
+.chip.active {
+  background: #1d4ed8;
+  border-color: #1d4ed8;
+  color: #fff;
+}
+.timeline-strip {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 14px;
+  align-items: center;
+  padding: 10px 14px;
+  margin: 10px 0;
+  border: 1px solid var(--line, #d8dce5);
+  border-radius: 10px;
+  font-size: 12.5px;
+}
+.stage-bars {
+  display: grid;
+  gap: 6px;
+}
+.stage-bar-row {
+  display: grid;
+  grid-template-columns: 200px minmax(80px, 1fr) auto;
+  gap: 10px;
+  align-items: center;
+  font-size: 12.5px;
+}
+.stage-bar-row.failed .stage-bar-fill {
+  background: #dc2626;
+}
+.stage-bar-row.failed .stage-bar-value {
+  color: #dc2626;
+}
+.stage-bar-label {
+  font-family: var(--mono, ui-monospace, monospace);
+}
+.stage-bar-track {
+  background: rgba(125, 135, 155, 0.16);
+  border-radius: 5px;
+  height: 10px;
+  overflow: hidden;
+}
+.stage-bar-fill {
+  display: block;
+  height: 100%;
+  background: #2563eb;
+  border-radius: 5px;
+}
+.event-list {
+  list-style: none;
+  margin: 10px 0 0;
+  padding: 0;
+  display: grid;
+  gap: 8px;
+}
+.event-row {
+  border: 1px solid var(--line, #d8dce5);
+  border-radius: 10px;
+  padding: 8px 12px;
+}
+.event-row.failed {
+  border-color: #dc2626;
+}
+.event-row-head {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  align-items: center;
+  font-size: 12.5px;
+}
+.event-type {
+  font-weight: 600;
+}
+.event-payload {
+  margin: 8px 0 0;
+  padding: 10px;
+  border-radius: 8px;
+  background: rgba(125, 135, 155, 0.1);
+  font-size: 11.5px;
+  overflow-x: auto;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+.error-state.inline {
+  display: inline;
+  padding: 1px 8px;
+  margin: 0;
+}
+.config-table-wrap {
+  overflow-x: auto;
+}
+.config-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 12px;
+}
+.config-table th,
+.config-table td {
+  border: 1px solid var(--line, #d8dce5);
+  padding: 6px 8px;
+  text-align: left;
+  vertical-align: top;
+  max-width: 360px;
+  word-break: break-all;
+}
+.config-table th {
+  background: rgba(125, 135, 155, 0.1);
+  font-weight: 600;
+}

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

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

+ 4 - 1
web/components/layout/AppShell.tsx

@@ -25,10 +25,13 @@ export function AppShell({
             ) : null}
           </div>
           <Link className="brand" href="/runs">
-            <strong>V1 可视化工作台</strong>
+            <strong>ContentFind 可视化工作台</strong>
           </Link>
           <div className="toolbar">
             {toolbarLeading}
+            <Link className="back-link" href="/config">
+              配置
+            </Link>
             <span className="badge">
               <Activity size={13} />
               Douyin V1

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

@@ -0,0 +1,258 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import {
+  getConfigQueryPrompts,
+  getConfigRulePacks,
+  getConfigWalkStrategy
+} from "@/lib/api/client";
+import type { ConfigFileResponse } from "@/lib/api/types";
+import { AppShell } from "@/components/layout/AppShell";
+import { FactGrid } from "@/components/cards/FactGrid";
+
+const TABS = [
+  { id: "rule-packs", label: "规则包" },
+  { id: "walk-strategy", label: "游走策略" },
+  { id: "query-prompts", label: "Query Prompts" }
+] as const;
+
+type TabId = (typeof TABS)[number]["id"];
+
+function cell(value: unknown): string {
+  if (value == null || value === "") return "—";
+  if (typeof value === "boolean") return value ? "是" : "否";
+  if (typeof value === "object") return JSON.stringify(value, null, 0);
+  return String(value);
+}
+
+function ConfigTable({
+  title,
+  note,
+  rows,
+  fields
+}: {
+  title: string;
+  note?: string;
+  rows: Array<Record<string, unknown>>;
+  fields: string[];
+}) {
+  return (
+    <section className="section">
+      <h3 className="section-title">
+        {title} <span className="muted">{rows.length} 条</span>
+      </h3>
+      {note ? <p className="muted">{note}</p> : null}
+      {rows.length === 0 ? (
+        <div className="empty-state">无条目。</div>
+      ) : (
+        <div className="config-table-wrap">
+          <table className="config-table">
+            <thead>
+              <tr>
+                {fields.map((field) => (
+                  <th key={field}>{field}</th>
+                ))}
+              </tr>
+            </thead>
+            <tbody>
+              {rows.map((row, index) => (
+                <tr key={index}>
+                  {fields.map((field) => (
+                    <td key={field}>{cell(row[field])}</td>
+                  ))}
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      )}
+    </section>
+  );
+}
+
+function asRows(value: unknown): Array<Record<string, unknown>> {
+  return Array.isArray(value) ? (value as Array<Record<string, unknown>>) : [];
+}
+
+function asRecord(value: unknown): Record<string, unknown> {
+  return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
+}
+
+function RulePacksTab({ data }: { data: Record<string, unknown> }) {
+  const dispatch = asRows(data.rule_pack_dispatch);
+  const packs = asRows(data.rule_packs);
+  const contentPack = asRecord(
+    packs.find((pack) => pack.rule_pack_id === "douyin_content_discovery_rule_pack_v1") || packs[0]
+  );
+  const scorecard = asRecord(contentPack.scorecard);
+  return (
+    <>
+      <ConfigTable
+        title="Dispatch(按对象选择规则包)"
+        note="dispatch_enabled=否 即 future 包:已归属未运行(M3 拍板,启用需打开 dispatch)。"
+        rows={dispatch}
+        fields={["dispatch_id", "target_entity", "content_format", "runtime_stage", "dispatch_enabled", "rule_pack_id", "rule_pack_version"]}
+      />
+      <ConfigTable
+        title={`Hard Gates(${String(contentPack.rule_pack_id || "Content 包")})`}
+        note="missing_content_portrait 的 KEEP_CONTENT_FOR_REVIEW/review 即 M3 画像止血;动作/严重度全部配置驱动,代码无业务硬编码。"
+        rows={asRows(contentPack.hard_gates)}
+        fields={["gate_id", "label", "when", "decision_action", "severity", "stop_scoring", "priority"]}
+      />
+      <ConfigTable
+        title="Scorecard 维度"
+        rows={asRows(scorecard.dimensions)}
+        fields={["key", "label", "max_score", "weight_percent", "runtime_status"]}
+      />
+      <ConfigTable
+        title="Scorecard 评分规则"
+        rows={asRows(scorecard.scoring_rules)}
+        fields={["scoring_rule_id", "dimension_key", "field_path", "operator", "expected_value", "score_value", "enabled"]}
+      />
+      <ConfigTable
+        title="阈值(Thresholds)"
+        rows={asRows(contentPack.thresholds)}
+        fields={["min_score", "max_score", "decision_action", "decision_reason_code", "effect_status", "enabled"]}
+      />
+      <ConfigTable
+        title="效果状态映射(effect_status_mapping)"
+        note="含 M3 新增的 hard gate KEEP→pending 映射(map_keep_for_review_pending_hard_gate)。"
+        rows={asRows(data.effect_status_mapping)}
+        fields={["mapping_id", "decision_action", "reason_category", "is_hard_gate", "content_effect_status", "query_effect_status", "priority", "enabled"]}
+      />
+      <ConfigTable
+        title="决策原因码"
+        rows={asRows(data.decision_reason_codes)}
+        fields={["decision_reason_code", "reason_label", "reason_category", "is_hard_gate"]}
+      />
+    </>
+  );
+}
+
+function WalkStrategyTab({ data }: { data: Record<string, unknown> }) {
+  return (
+    <>
+      <ConfigTable
+        title="边→规则包归属(walk_rule_pack_binding)"
+        note="M4 增补 path_stop / decision_to_asset 后共 8 条;归属包写在 walk action 顶层 rule_pack_id,实际执行包写在 raw_payload.rule_pack_execution。"
+        rows={asRows(data.walk_rule_pack_binding)}
+        fields={["binding_id", "edge_id", "target_entity", "rule_pack_id", "dispatch_policy", "required"]}
+      />
+      <ConfigTable
+        title="游走边目录(walk_edge_catalog)"
+        rows={asRows(data.walk_edge_catalog)}
+        fields={["edge_id", "edge_type", "priority", "can_loop", "notes"]}
+      />
+      <ConfigTable
+        title="预算策略(walk_budget_policy)"
+        rows={asRows(data.walk_budget_policy)}
+        fields={["budget_id", "edge_id", "max_total_actions", "max_per_query", "max_pages", "max_depth", "max_tag_hops"]}
+      />
+      <ConfigTable
+        title="停止策略(walk_stop_policy)"
+        rows={asRows(data.walk_stop_policy)}
+        fields={["stop_policy_id", "edge_id", "condition_label", "field_path", "expected_value", "stop_action", "priority"]}
+      />
+    </>
+  );
+}
+
+function QueryPromptsTab({ data }: { data: Record<string, unknown> }) {
+  const profiles = asRecord(data.profiles);
+  return (
+    <>
+      {Object.entries(profiles).map(([name, raw]) => {
+        const profile = asRecord(raw);
+        const genericFilter = asRecord(profile.generic_filter);
+        return (
+          <section className="section" key={name}>
+            <h3 className="section-title">Profile · {name}</h3>
+            <FactGrid
+              rows={[
+                ["prompt_version", profile.prompt_version],
+                ["temperature", profile.temperature],
+                ["max_tokens", profile.max_tokens],
+                ["variants_per_seed", `${cell(profile.variants_per_seed)}(>1 暂不支持,M2 拍板)`],
+                ["evidence_fields", `${asRows(profile.evidence_fields).length || (profile.evidence_fields as unknown[] | undefined)?.length || 0} 个字段`],
+                ["泛词过滤 queries", ((genericFilter.queries as unknown[]) || []).join("、")],
+                ["泛词过滤 tokens", ((genericFilter.tokens as unknown[]) || []).join("、")]
+              ]}
+            />
+            <details>
+              <summary className="muted">system / user prompt 原文(逐字复刻,运行真相源)</summary>
+              <pre className="event-payload">{cell(profile.system)}</pre>
+              <pre className="event-payload">{cell(profile.user)}</pre>
+            </details>
+          </section>
+        );
+      })}
+    </>
+  );
+}
+
+export function ConfigPage() {
+  const [tab, setTab] = useState<TabId>("rule-packs");
+  const [responses, setResponses] = useState<Partial<Record<TabId, ConfigFileResponse>>>({});
+  const [error, setError] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  const load = useCallback(async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const [rulePacks, walkStrategy, queryPrompts] = await Promise.all([
+        getConfigRulePacks(),
+        getConfigWalkStrategy(),
+        getConfigQueryPrompts()
+      ]);
+      setResponses({
+        "rule-packs": rulePacks,
+        "walk-strategy": walkStrategy,
+        "query-prompts": queryPrompts
+      });
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "配置加载失败");
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    void load();
+  }, [load]);
+
+  const current = responses[tab];
+
+  return (
+    <AppShell onRefresh={load} showBack>
+      <section className="detail-panel">
+        <header className="detail-header">
+          <div>
+            <h1 className="detail-title">配置即真相(只读)</h1>
+            <p className="muted">
+              运行时读取的 JSON 配置原文。只读展示;编辑走 Excel→JSON 工具链(可视化编辑留 Web V3)。
+            </p>
+          </div>
+          {current ? <span className="muted">{current.source_file}</span> : null}
+        </header>
+        <div className="filter-bar">
+          {TABS.map((option) => (
+            <button
+              key={option.id}
+              type="button"
+              className={`chip${tab === option.id ? " active" : ""}`}
+              onClick={() => setTab(option.id)}
+            >
+              {option.label}
+            </button>
+          ))}
+        </div>
+        {loading ? <div className="loading-state">配置加载中…</div> : null}
+        {error ? <div className="error-state">{error}</div> : null}
+        {current && tab === "rule-packs" ? <RulePacksTab data={current.data} /> : null}
+        {current && tab === "walk-strategy" ? <WalkStrategyTab data={current.data} /> : null}
+        {current && tab === "query-prompts" ? <QueryPromptsTab data={current.data} /> : null}
+      </section>
+    </AppShell>
+  );
+}

+ 45 - 3
web/features/runs/RunDashboardPage.tsx

@@ -1,5 +1,7 @@
 "use client";
 
+import Link from "next/link";
+
 import {
   ChevronRight,
   FileJson,
@@ -30,7 +32,7 @@ import type {
   StageConclusion,
   TimelineResponse,
 } from "@/lib/api/types";
-import { compactValue } from "@/lib/status/status";
+import {compactValue, statusLabel } from "@/lib/status/status";
 
 type DashboardData = {
   dashboard: DashboardResponse;
@@ -51,7 +53,9 @@ type DrawerContent =
 async function optionalRuntimeFile(runId: string, filename: string): Promise<RuntimeFileResponse | null> {
   try {
     return await getRuntimeFile(runId, filename, 80);
-  } catch {
+  } catch (err) {
+    // 可选文件加载失败不再静默:控制台留痕,页面对应区块显示"缺失"。
+    console.warn(`runtime file load failed: ${filename}`, err);
     return null;
   }
 }
@@ -138,6 +142,7 @@ export function RunDashboardPage({ runId }: { runId: string }) {
             activeStage={activeStage}
             onSelect={setActiveStage}
           />
+          <TimelineSummaryStrip runId={runId} timeline={data.timeline} />
           <StagePanel
             activeStage={activeStage}
             data={data}
@@ -153,6 +158,27 @@ export function RunDashboardPage({ runId }: { runId: string }) {
   );
 }
 
+function TimelineSummaryStrip({ runId, timeline }: { runId: string; timeline: TimelineResponse }) {
+  const summary = timeline.summary;
+  const decodeLine = Object.entries(summary?.decode_status_counts || {})
+    .map(([key, value]) => `${statusLabel(key)} ${value}`)
+    .join(" / ");
+  return (
+    <div className="timeline-strip">
+      <span>
+        总耗时 {summary?.total_duration_ms == null ? "未知" : `${summary.total_duration_ms} ms`}
+      </span>
+      <span>query 失败 {summary?.query_failure_count ?? 0}</span>
+      <span>平台限流 {summary?.platform_rate_limited_count ?? 0}</span>
+      <span>decode {decodeLine || "无"}</span>
+      <span>事件 {timeline.total} 条</span>
+      <Link className="text-button" href={`/runs/${encodeURIComponent(runId)}/timeline`}>
+        查看完整时间线 / 日志
+      </Link>
+    </div>
+  );
+}
+
 function StageNavigationCards({
   dashboard,
   stages,
@@ -710,10 +736,15 @@ function rulePackLabel(rulePack: unknown): string {
   const value = String(rulePack || "");
   const labels: Record<string, string> = {
     douyin_content_discovery_rule_pack_v1: "内容发现规则包 V1(抖音)",
+    douyin_path_stop_rule_pack_v1: "路径停止规则包 V1(future)",
+    douyin_budget_observe_rule_pack_v1: "预算观察规则包 V1(future)",
+    douyin_author_expand_rule_pack_v1: "作者扩展规则包 V1(future)",
+    douyin_tag_expansion_rule_pack_v1: "tag 扩散规则包 V1(future)",
     Content_Rule_Pack_V1: "内容发现规则包 V1",
     "Content Rule Pack V1": "内容发现规则包 V1"
   };
-  return labels[value] || compactValue(rulePack || "内容发现规则包 V1");
+  // fallback 显示原始 id,不再兜底成误导性的默认中文名。
+  return labels[value] || (value ? value : "未记录");
 }
 
 function hardGateLabel(item: RuleApplicationSummary): string {
@@ -823,6 +854,7 @@ function WalkGraphCanvas({
             type="button"
           >
             {edge.label}
+            {edgeExecutionSuffix(edge.payload)}
           </button>
         ))}
       </div>
@@ -830,6 +862,16 @@ function WalkGraphCanvas({
   );
 }
 
+// M4 归属/执行分离:边归属包(rule_pack)与实际执行包(executed_rule_pack_id)可能不同。
+function edgeExecutionSuffix(payload: Record<string, unknown>): string {
+  if (!payload.rule_pack) return "";
+  if (payload.rule_pack_executed === false) return " · 已归属未运行";
+  if (payload.executed_rule_pack_id && payload.executed_rule_pack_id !== payload.rule_pack) {
+    return " · 执行:内容包";
+  }
+  return "";
+}
+
 function TechnicalDetailsDrawer({
   drawer,
   onClose

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

@@ -8,6 +8,7 @@ import { DataOriginBadge, StatusBadge } from "@/components/badges/StatusBadge";
 import { listRuns } from "@/lib/api/client";
 import type { RunListItem, RunListResponse } from "@/lib/api/types";
 
+// run 级状态仅此 4 种(RunState.status 合同);pending/rule_blocked 是内容/决策级状态,不在此列。
 const statusOptions = ["", "success", "partial_success", "failed", "running"];
 
 export function RunListPage() {
@@ -134,6 +135,7 @@ function RunCard({ item }: { item: RunListItem }) {
       </div>
       <div className="run-card-meta">
         <span>{item.started_at || "no started_at"}</span>
+        {item.validation_status ? <span>校验:{item.validation_status}</span> : null}
         {item.error_code ? <span>{item.error_code}</span> : null}
       </div>
     </Link>

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

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

+ 30 - 39
web/lib/adapters/pipeline.ts

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

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

@@ -1,4 +1,5 @@
 import type {
+  ConfigFileResponse,
   ContentItemsResponse,
   DashboardResponse,
   QueryListResponse,
@@ -78,3 +79,15 @@ export function getRuntimeFile(runId: string, filename: string, limit = 100) {
     `/runs/${encodeURIComponent(runId)}/runtime-files/${safeFilename}?limit=${limit}`
   );
 }
+
+export function getConfigRulePacks() {
+  return request<ConfigFileResponse>("/config/rule-packs");
+}
+
+export function getConfigWalkStrategy() {
+  return request<ConfigFileResponse>("/config/walk-strategy");
+}
+
+export function getConfigQueryPrompts() {
+  return request<ConfigFileResponse>("/config/query-prompts");
+}

+ 30 - 1
web/lib/api/types.ts

@@ -91,6 +91,8 @@ export type WalkGraphEdge = {
   label?: string | null;
   status?: string | null;
   rule_pack?: string | null;
+  rule_pack_executed?: boolean | null;
+  executed_rule_pack_id?: string | null;
   budget_tier?: string | null;
   reason_code?: string | null;
 };
@@ -117,11 +119,33 @@ export type QueryListResponse = {
   data_origin: DataOrigin;
 };
 
+export type TimelineSummary = {
+  total_duration_ms: number | null;
+  stage_duration_ms: Record<string, number>;
+  query_failure_count: number;
+  platform_rate_limited_count: number;
+  decode_status_counts: Record<string, number>;
+  error_counts: Record<string, number>;
+  walk_status_counts: Record<string, number>;
+};
+
+export type TimelineItem = {
+  source: string;
+  stage?: string | null;
+  event_type?: string | null;
+  status?: string | null;
+  timestamp?: string | null;
+  error_code?: string | null;
+  walk_action_id?: string | null;
+  record: Record<string, unknown>;
+};
+
 export type TimelineResponse = {
   run_id: string;
-  items: Array<Record<string, unknown>>;
+  items: TimelineItem[];
   total: number;
   data_origin: DataOrigin;
+  summary: TimelineSummary;
 };
 
 export type ContentItemsResponse = {
@@ -147,3 +171,8 @@ export type RuntimeFileResponse = {
   limit?: number;
   total?: number;
 };
+
+export type ConfigFileResponse = {
+  source_file: string;
+  data: Record<string, unknown>;
+};

+ 19 - 1
web/lib/status/status.ts

@@ -8,7 +8,25 @@ export function statusLabel(status: unknown) {
     rule_blocked: "规则阻断",
     running: "运行中",
     pass: "通过",
-    fail: "未通过"
+    fail: "未通过",
+    skipped: "跳过",
+    // M6 阶段事件
+    stage_started: "阶段开始",
+    stage_completed: "阶段完成",
+    stage_failed: "阶段失败",
+    // M6 decode 中间事件(事件类型与 summary 计数键)
+    decode_submitted: "解码已提交",
+    decode_polling: "解码轮询中",
+    decode_succeeded: "解码成功",
+    decode_failed: "解码失败",
+    decode_timeout: "解码超时",
+    submitted: "已提交",
+    polling: "轮询中",
+    succeeded: "成功",
+    timeout: "超时",
+    // M5 限流分类
+    PLATFORM_RATE_LIMITED: "平台限流",
+    PLATFORM_REQUEST_FAILED: "平台请求失败"
   };
   return labels[value] || value;
 }