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

feat(web-v3): StagePanel 收缩 7→5 + 旅程视频详情增强(合一批3+4)

- 删 query/platform/judge 三 StagePanel 分支 + 清 ~270 行死代码(ContentPostCard/RuleApplicationCard/GeminiJudgeRow/ScorecardRow/query 助手)+ 无用 import
- 旅程视频详情新增:VideoDetailMedia(懒加载播放器,复用 content-card-video)+ ScorecardDetail(评分明细),只在 wj-vid-detail 内,树骨架/配色不动
- llm 搜索块来历行加「查看 AI 扩写提示词」按钮(llmVariantPromptPayload→technical drawer);onOpen 扩展 kind

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sam Lee 2 дней назад
Родитель
Сommit
4852a46283
3 измененных файлов с 107 добавлено и 394 удалено
  1. 7 0
      web/app/globals.css
  2. 3 388
      web/features/runs/RunDashboardPage.tsx
  3. 97 6
      web/features/runs/WalkJourney.tsx

+ 7 - 0
web/app/globals.css

@@ -1703,3 +1703,10 @@ a {
 .panel-nav-top strong { font-size: 14px; font-weight: 900; color: #172033; }
 .panel-nav-card.active .panel-nav-top strong { color: #2360ad; }
 .panel-nav-sub { font-size: 11.5px; color: #8491a6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* 旅程视频详情:播放器 + AI 扩写提示词入口 */
+.wj-vid-media { margin: 6px 0 2px; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
+.wj-vid-media .content-card-video { margin: 0; }
+.wj-media-origin { align-self: flex-start; }
+.wj-prompt-link { color: #2360ad; font-weight: 600; }
+.wj-prompt-link:hover { text-decoration: underline; }

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

@@ -4,14 +4,10 @@ import Link from "next/link";
 import { WalkJourney } from "@/features/runs/WalkJourney";
 
 import {
-  ChevronDown,
   ChevronRight,
   FileJson,
-  PlayCircle,
   GitBranch,
-  ListFilter,
   PanelRightOpen,
-  ShieldCheck,
   Target
 } from "lucide-react";
 import { useCallback, useEffect, useMemo, useState } from "react";
@@ -36,8 +32,6 @@ import type {
   TimelineResponse,
 } from "@/lib/api/types";
 import {compactValue, statusLabel } from "@/lib/status/status";
-import { contentUrl, embedPlayerUrl, platformLabel } from "@/lib/platform/content";
-import { isLlmVariantQuery, llmVariantPromptPayload } from "@/features/runs/queryPrompt";
 
 type DashboardData = {
   dashboard: DashboardResponse;
@@ -307,74 +301,6 @@ function stageCount(dashboard: DashboardResponse, stageId: string): number | nul
   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 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 runtimeData(file: RuntimeFileResponse | null): Record<string, unknown> {
   if (!file?.data || typeof file.data !== "object") {
     return {};
@@ -482,112 +408,6 @@ function StagePanel({
       </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) => (
-            <ContentPostCard item={item} index={index} key={String(item.platform_content_id || index)} />
-          )) : <div className="empty-state">还没有发现内容;请先查看 Query 或平台失败原因</div>}
-        </div>
-      </BusinessSection>
-    );
-  }
-  if (activeStage === "judge") {
-    const itemByContentId = new Map<string, Record<string, unknown>>();
-    data.contentItems.items.forEach((it) => {
-      const id = String(it.platform_content_id || "");
-      if (id) itemByContentId.set(id, it);
-    });
-    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>Gemini 直读判定</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}
-              contentItem={itemByContentId.get(String(item.platform_content_id || ""))}
-              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} />}>
@@ -597,10 +417,10 @@ function StagePanel({
           contentItems={data.contentItems}
           queries={data.queries.items}
           fallbackEdges={data.dashboard.walk_graph.edges}
-          onOpen={(payload) =>
+          onOpen={(payload, kind = "walk") =>
             onOpenDrawer({
-              kind: "walk",
-              title: String(payload.title || payload.id || "视频旅程详情"),
+              kind,
+              title: String(payload["这个提示词在做什么"] ? "AI 扩写提示词" : payload.title || payload.id || "视频旅程详情"),
               payload
             })
           }
@@ -649,102 +469,6 @@ function StagePanel({
   );
 }
 
-function ContentPostCard({ item, index }: { item: Record<string, unknown>; index: number }) {
-  const [open, setOpen] = useState(true);
-  const [videoOn, setVideoOn] = useState(false);
-  const querySource = primaryQuerySource(item);
-  const contentId = String(item.platform_content_id || "");
-  const platform = String(item.platform || "");
-  const pLabel = platformLabel(platform);
-  const playerUrl = embedPlayerUrl(item); // 仅抖音有官方嵌入播放器;其余平台 null
-  const origin = contentUrl(item);
-  return (
-    <div className="business-record-card">
-      <button
-        type="button"
-        className="content-card-toggle"
-        aria-expanded={open}
-        onClick={() => setOpen((v) => !v)}
-      >
-        {open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
-        <span className="content-card-heading">
-          <span>{pLabel}内容 #{index + 1}</span>
-          <strong>{compactValue(item.title || item.description || `${pLabel}视频`)}</strong>
-        </span>
-      </button>
-      {open ? (
-        <div className="content-card-body">
-          <div className="content-card-detail">
-            <div className="content-meta-grid">
-              <span>
-                {pLabel}视频 ID
-                <strong>{compactValue(item.platform_content_id)}</strong>
-              </span>
-              <span>
-                来源 Query
-                <strong>{compactValue(querySource.search_query_id)}</strong>
-              </span>
-              <span>
-                搜索关键词
-                <strong>{compactValue(querySource.search_query)}</strong>
-              </span>
-              <span>
-                Query 类型
-                <strong>{queryGenerationMethodLabel(querySource.search_query_generation_method)}</strong>
-              </span>
-              <span>
-                {pLabel}作者
-                <strong>{compactValue(item.author_display_name || item.platform_author_id)}</strong>
-              </span>
-            </div>
-            <div className="business-action-row">
-              {origin ? (
-                <a className="text-button" href={origin} rel="noreferrer" target="_blank">
-                  打开原帖
-                </a>
-              ) : (
-                <span className="muted">该平台未提供原帖链接</span>
-              )}
-            </div>
-          </div>
-          <div className="content-card-video">
-            {!contentId ? (
-              <div className="content-video-placeholder muted">无视频 ID</div>
-            ) : !playerUrl ? (
-              <div className="content-video-placeholder muted">
-                {pLabel}无公开嵌入播放器
-                {origin ? (
-                  <a className="text-button" href={origin} rel="noreferrer" target="_blank">
-                    打开原帖
-                  </a>
-                ) : null}
-              </div>
-            ) : videoOn ? (
-              <iframe
-                className="content-video-frame"
-                src={playerUrl}
-                title={`${pLabel}视频 ${contentId}`}
-                loading="lazy"
-                allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
-                allowFullScreen
-              />
-            ) : (
-              <button
-                type="button"
-                className="content-video-placeholder content-video-load"
-                onClick={() => setVideoOn(true)}
-              >
-                <PlayCircle size={36} />
-                <span>点击播放视频</span>
-              </button>
-            )}
-          </div>
-        </div>
-      ) : null}
-    </div>
-  );
-}
-
 function BusinessSection({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) {
   return (
     <section className="business-section">
@@ -769,115 +493,6 @@ function ConclusionBody({ stage }: { stage?: StageConclusion }) {
   );
 }
 
-function RuleApplicationCard({
-  item,
-  contentItem,
-  onOpen
-}: {
-  item: RuleApplicationSummary;
-  contentItem?: Record<string, unknown>;
-  onOpen: () => void;
-}) {
-  const title = item.content_title && item.content_title !== item.platform_content_id ? item.content_title : "视频内容";
-  const platform = contentItem ? String(contentItem.platform || "") : "";
-  const pmr = (contentItem?.pattern_match_result as Record<string, unknown>) || null;
-  const idLabel = platform ? `${platformLabel(platform)}视频 ID` : "视频 ID";
-  return (
-    <div className="rule-application-card">
-      <div className="rule-card-heading">
-        <span>判断对象</span>
-        <strong>{compactValue(title)}</strong>
-        <small>{idLabel}:{compactValue(item.platform_content_id)}</small>
-      </div>
-      {pmr ? <GeminiJudgeRow pmr={pmr} /> : null}
-      <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>
-      <ScorecardRow contentItem={contentItem} />
-      <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 GeminiJudgeRow({ pmr }: { pmr: Record<string, unknown> }) {
-  const fit = pmr.fit_senior_50plus;
-  const conf = pmr.fit_confidence;
-  const rel = pmr.relevance_score;
-  const status = String(pmr.judge_status || "");
-  const reason = String(pmr.reason || "");
-  const quota = status === "gemini_quota_exhausted";
-  // 仅 V3 Gemini 判定有这些字段;V2 run 无任何判定信号则整行不渲染。
-  const hasV3Signal = status !== "" || typeof fit === "boolean" || rel != null || conf != null;
-  if (!hasV3Signal) return null;
-  return (
-    <div className={`gemini-judge-row${quota ? " quota" : ""}`}>
-      <span className="gemini-judge-tag">Gemini 判定</span>
-      {quota ? (
-        <span className="gemini-judge-quota">⚠ 配额截断,未判定</span>
-      ) : (
-        <>
-          <span>适合 50+:{fit === true ? "是" : fit === false ? "否" : "—"}</span>
-          <span>置信度:{conf == null ? "—" : compactValue(conf)}</span>
-          <span>相关性:{rel == null ? "—" : compactValue(rel)}</span>
-          <span>状态:{statusLabel(status)}</span>
-        </>
-      )}
-      {reason ? <small className="gemini-judge-reason">{reason}</small> : null}
-    </div>
-  );
-}
-
-function ScorecardRow({ contentItem }: { contentItem?: Record<string, unknown> }) {
-  const decision = (contentItem?.rule_decision as Record<string, unknown>) || null;
-  const scorecard = (decision?.scorecard as Record<string, unknown>) || null;
-  const dims = (scorecard?.dimensions as Array<Record<string, unknown>>) || [];
-  if (!dims.length) return null;
-  const DEPRECATED = new Set([
-    "content_audience_profile",
-    "interaction_performance",
-    "freshness_available",
-    "douyin_tone",
-    "adaptability"
-  ]);
-  return (
-    <div className="scorecard-row">
-      <span className="scorecard-label">评分维度</span>
-      {dims.map((d, i) => {
-        const key = String(d.key || "");
-        const deprecated = DEPRECATED.has(key);
-        return (
-          <span className={`scorecard-chip${deprecated ? " deprecated" : ""}`} key={key || i}>
-            {key}:{d.score == null ? "—" : compactValue(d.score)}
-            {d.max_score != null ? ` / ${compactValue(d.max_score)}` : ""}
-            {deprecated ? "(弃用)" : ""}
-          </span>
-        );
-      })}
-    </div>
-  );
-}
-
 function rulePackLabel(rulePack: unknown): string {
   const value = String(rulePack || "");
   const labels: Record<string, string> = {

+ 97 - 6
web/features/runs/WalkJourney.tsx

@@ -19,7 +19,8 @@ import {
 } from "lucide-react";
 import { getConfigRulePacks, getRuntimeFile } from "@/lib/api/client";
 import type { ContentItemsResponse, WalkGraphEdge } from "@/lib/api/types";
-import { platformLabel } from "@/lib/platform/content";
+import { contentUrl, embedPlayerUrl, platformLabel } from "@/lib/platform/content";
+import { llmVariantPromptPayload } from "@/features/runs/queryPrompt";
 
 type AnyRec = Record<string, unknown>;
 
@@ -72,6 +73,8 @@ type VideoJourney = {
   score: unknown;
   downstreamCn: string[];
   page?: number; // 这条视频来自该搜索词的第几页(翻页结构化用)
+  scorecard?: AnyRec; // rule_decision.scorecard,详情里展示评分明细
+  item: AnyRec; // 原始 content item,详情里取播放器/原帖 URL
 };
 
 function buildVideos(items: AnyRec[], actionsByFrom: Map<string, Set<string>>): VideoJourney[] {
@@ -95,7 +98,9 @@ function buildVideos(items: AnyRec[], actionsByFrom: Map<string, Set<string>>):
       judgeStatus: String(pmr.judge_status || ""),
       reason: String(pmr.reason || ""),
       score: rd.score,
-      downstreamCn: [...(actionsByFrom.get(id) || [])]
+      downstreamCn: [...(actionsByFrom.get(id) || [])],
+      scorecard: (rd.scorecard as AnyRec) || undefined,
+      item: it
     };
   });
 }
@@ -129,7 +134,7 @@ export function WalkJourney({
   contentItems: ContentItemsResponse;
   queries: AnyRec[];
   fallbackEdges: WalkGraphEdge[];
-  onOpen: (payload: Record<string, unknown>) => void;
+  onOpen: (payload: Record<string, unknown>, kind?: "walk" | "technical") => void;
 }) {
   const [walkActions, setWalkActions] = useState<AnyRec[] | null>(null);
   const [searchQueries, setSearchQueries] = useState<AnyRec[] | null>(null);
@@ -195,6 +200,15 @@ export function WalkJourney({
     return t;
   }, [videos]);
 
+  // search_query_id → 原始 query(含 llm_input_evidence),供搜索块 AI 扩写 prompt 入口。
+  const queryById = useMemo(() => {
+    const m: Record<string, AnyRec> = {};
+    queries.forEach((q) => {
+      m[String(q.search_query_id)] = q;
+    });
+    return m;
+  }, [queries]);
+
   // ===== 路径树血缘模型 =====
   const model: TreeModel = useMemo(() => {
     const sq = (searchQueries && searchQueries.length ? searchQueries : queries) || [];
@@ -368,6 +382,7 @@ export function WalkJourney({
       ) : (
         <WalkTree
           model={model}
+          queryById={queryById}
           openVideo={openVideo}
           setOpenVideo={setOpenVideo}
           openBlocks={openBlocks}
@@ -537,9 +552,71 @@ function WalkMap({ counts, videoCount, tally }: { counts: Record<string, number>
   );
 }
 
+// 评分明细(复用 judge 的 scorecard 渲染),空 dims 不渲染。
+function ScorecardDetail({ scorecard }: { scorecard?: AnyRec }) {
+  const dims = (scorecard?.dimensions as AnyRec[]) || [];
+  if (!dims.length) return null;
+  const DEPRECATED = new Set(["content_audience_profile", "interaction_performance", "freshness_available", "douyin_tone", "adaptability"]);
+  return (
+    <div className="scorecard-row">
+      <span className="scorecard-label">评分</span>
+      {dims.map((d, i) => {
+        const key = String(d.key || "");
+        const dep = DEPRECATED.has(key);
+        return (
+          <span className={`scorecard-chip${dep ? " deprecated" : ""}`} key={key || i}>
+            {key}:{d.score == null ? "—" : String(d.score)}
+            {d.max_score != null ? ` / ${String(d.max_score)}` : ""}
+            {dep ? "(弃用)" : ""}
+          </span>
+        );
+      })}
+    </div>
+  );
+}
+
+// 视频播放器(懒加载 iframe,仅抖音有 embed;复用 content-card-video CSS),无 ID 不渲染。
+function VideoDetailMedia({ item }: { item: AnyRec }) {
+  const [videoOn, setVideoOn] = useState(false);
+  const contentId = String(item.platform_content_id || "");
+  if (!contentId) return null;
+  const pLabel = platformLabel(item.platform);
+  const playerUrl = embedPlayerUrl(item);
+  const origin = contentUrl(item);
+  return (
+    <div className="wj-vid-media">
+      <div className="content-card-video">
+        {!playerUrl ? (
+          <div className="content-video-placeholder muted">{pLabel}无公开嵌入播放器</div>
+        ) : videoOn ? (
+          <iframe
+            className="content-video-frame"
+            src={playerUrl}
+            title={`${pLabel}视频 ${contentId}`}
+            loading="lazy"
+            allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
+            allowFullScreen
+          />
+        ) : (
+          <button type="button" className="content-video-placeholder content-video-load" onClick={() => setVideoOn(true)}>
+            <PlayCircle size={32} />
+            <span>点击播放视频</span>
+          </button>
+        )}
+      </div>
+      {origin ? (
+        <a className="text-button wj-media-origin" href={origin} target="_blank" rel="noreferrer">
+          打开原帖
+        </a>
+      ) : null}
+    </div>
+  );
+}
+
 // ===== A 路径树:搜索块竖排 + 来历回指 + 作者作品递进嵌套 =====
 function WalkTree({
   model,
+  queryById,
   openVideo,
   setOpenVideo,
   openBlocks,
@@ -550,13 +627,14 @@ function WalkTree({
   onRule
 }: {
   model: TreeModel;
+  queryById: Record<string, AnyRec>;
   openVideo: string | null;
   setOpenVideo: (id: string | null) => void;
   openBlocks: Set<string>;
   setOpenBlocks: (fn: (prev: Set<string>) => Set<string>) => void;
   highlight: string | null;
   jumpTo: (anchor?: string, blockQid?: string) => void;
-  onOpen: (payload: Record<string, unknown>) => void;
+  onOpen: (payload: Record<string, unknown>, kind?: "walk" | "technical") => void;
   onRule: () => void;
 }) {
   function toggleBlock(qid: string) {
@@ -604,7 +682,9 @@ function WalkTree({
                 <span className="wj-reason">{v.reason}</span>
               </div>
             ) : null}
-            <button type="button" className="text-button wj-detail-btn" onClick={() => onOpen({ ...v } as Record<string, unknown>)}>
+            <ScorecardDetail scorecard={v.scorecard} />
+            <VideoDetailMedia item={v.item} />
+            <button type="button" className="text-button wj-detail-btn" onClick={() => onOpen({ id: v.id, title: v.title, action: v.action } as Record<string, unknown>)}>
               查看技术详情
             </button>
           </div>
@@ -670,7 +750,18 @@ function WalkTree({
                 <strong className="wj-block-title">搜索「{b.text}」</strong>
                 <span className="wj-q-count">{b.rootVideos.length} 条视频</span>
               </button>
-              <div className="wj-crumbs">{b.lineage.crumbs.map(crumb)}</div>
+              <div className="wj-crumbs">
+                {b.lineage.crumbs.map(crumb)}
+                {b.lineage.kind === "llm" && queryById[b.qid] ? (
+                  <button
+                    type="button"
+                    className="wj-crumb-link wj-prompt-link"
+                    onClick={() => onOpen(llmVariantPromptPayload(queryById[b.qid]), "technical")}
+                  >
+                    · 查看 AI 扩写提示词
+                  </button>
+                ) : null}
+              </div>
               {open ? (
                 <div className="wj-block-body">
                   {b.rootVideos.length