QueryWalkPage.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. "use client";
  2. import Link from "next/link";
  3. import { ArrowRight, ExternalLink, Hash, ListVideo, Search, UserSearch } from "lucide-react";
  4. import { useCallback, useEffect, useMemo, useState } from "react";
  5. import { AppShell } from "@/components/AppShell";
  6. import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
  7. import { getFlowLedgerWalk } from "@/lib/api/client";
  8. import type { FlowLedgerWalkResponse, RawRecord } from "@/lib/api/types";
  9. import { methodLabel } from "@/lib/flow-ledger/business";
  10. import { asRecord, text } from "@/lib/flow-ledger/format";
  11. type WalkGroup = {
  12. id: string;
  13. kind: "video" | "query";
  14. video?: RawRecord;
  15. title: string;
  16. author: string;
  17. decisionLabel: string;
  18. terminalLabel: string;
  19. actions: RawRecord[];
  20. };
  21. type WalkTree = {
  22. roots: WalkGroup[];
  23. childrenByActionId: Map<string, WalkGroup[]>;
  24. totalGroups: number;
  25. };
  26. export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: string }) {
  27. const [data, setData] = useState<FlowLedgerWalkResponse | null>(null);
  28. const [loading, setLoading] = useState(true);
  29. const [error, setError] = useState<string | null>(null);
  30. const load = useCallback(async () => {
  31. setLoading(true);
  32. setError(null);
  33. try {
  34. const debug = new URLSearchParams(window.location.search).get("debug") === "1";
  35. setData(await getFlowLedgerWalk(runId, queryId, debug));
  36. } catch (err) {
  37. setError(err instanceof Error ? err.message : String(err));
  38. } finally {
  39. setLoading(false);
  40. }
  41. }, [queryId, runId]);
  42. useEffect(() => {
  43. void load();
  44. }, [load]);
  45. const query = asRecord(data?.query);
  46. const actions = useMemo(() => [...(data?.actions || [])].sort((a, b) => actionDepth(a) - actionDepth(b)), [data?.actions]);
  47. const tree = useMemo(() => buildWalkTree(actions), [actions]);
  48. return (
  49. <AppShell title="继续扩展过程" subtitle={query.text ? `搜索词:${text(query.text)}` : "查看系统有没有继续向外找内容"} runId={runId} showBack onRefresh={load}>
  50. {loading ? <LoadingState /> : null}
  51. {error ? <ErrorState message={error} /> : null}
  52. {!loading && !error && !actions.length ? <EmptyState label="这个搜索词本轮没有继续扩展" /> : null}
  53. {data ? (
  54. <>
  55. <details className="declarations" open>
  56. <summary>
  57. <span className="kw">游走</span> <b>{text(query.text, "搜索词待确认")}</b>
  58. <span className="decl-purpose">{methodLabel(text(query.method, ""))}</span>
  59. <span className="tag-mini">{tree.totalGroups ? `${tree.totalGroups} 个起点` : "没有继续扩展"}</span>
  60. </summary>
  61. <div className="decl-body">
  62. <Summary label="怎么看" value="每个色块是一条起点视频;视频下面缩进的路线,就是它向外游走的标签、作者或翻页;如果带回的视频又继续游走,会继续向内缩进。" />
  63. <Summary label="本轮概况" value={`已执行 ${Number(data.summary.success_count || 0)} 条路线 · 未执行 ${Number(data.summary.skipped_count || 0)} 条路线 · 新增搜索词 ${Number(data.summary.extension_query_count || 0)} 个`} />
  64. </div>
  65. </details>
  66. <section className="walk-ledger-board">
  67. <div className="walk-ledger-root">
  68. <span className="chip dark">当前搜索词</span>
  69. <strong>{text(query.text, "搜索词待确认")}</strong>
  70. <small>{methodLabel(text(query.method, ""))}</small>
  71. </div>
  72. <div className="walk-ledger-groups">
  73. {tree.roots.map((group, index) => (
  74. <WalkGroupCard group={group} index={index} runId={runId} tree={tree} level={0} key={group.id} />
  75. ))}
  76. </div>
  77. </section>
  78. </>
  79. ) : null}
  80. </AppShell>
  81. );
  82. }
  83. function Summary({ label, value }: { label: string; value: string }) {
  84. return (
  85. <div className="decl-section">
  86. <div className="decl-label">{label}</div>
  87. <div className="decl-row">{value}</div>
  88. </div>
  89. );
  90. }
  91. function WalkGroupCard({
  92. group,
  93. index,
  94. runId,
  95. tree,
  96. level,
  97. seen = new Set<string>()
  98. }: {
  99. group: WalkGroup;
  100. index: number;
  101. runId: string;
  102. tree: WalkTree;
  103. level: number;
  104. seen?: Set<string>;
  105. }) {
  106. const routeActions = group.actions.filter((action) => !isTerminalEdge(text(action.edge_id, "")));
  107. const titleParts = splitTitleAndTags(group.title);
  108. const score = group.video ? scoreSummary(asRecord(group.video.decision).score_items) : "";
  109. const nextSeen = new Set(seen).add(group.id);
  110. return (
  111. <article className={`walk-story-block level-${Math.min(level, 3)}`} id={`source-${encodeURIComponent(group.id)}`}>
  112. <header className="walk-story-head">
  113. <span className="walk-source-kicker">{sourceKicker(group, index, level)}</span>
  114. <div className="walk-story-title">
  115. <h2>{titleParts.title}</h2>
  116. {titleParts.tags.length ? (
  117. <div className="walk-source-tags">
  118. {titleParts.tags.map((tag) => <span key={tag}>{tag}</span>)}
  119. </div>
  120. ) : null}
  121. </div>
  122. <VideoLinks video={group.video} runId={runId} />
  123. </header>
  124. <div className="walk-source-facts">
  125. {group.kind === "video" ? <Fact label="作者" value={group.author} /> : <Fact label="类型" value="搜索词级扩展" tone="info" />}
  126. {group.kind === "video" ? <Fact label="判断" value={group.decisionLabel} tone={decisionTone(group.decisionLabel)} /> : <Fact label="来源" value="从当前搜索词继续找" />}
  127. {score ? <Fact label="评分" value={score} /> : null}
  128. {group.terminalLabel ? <Fact label="处理" value={group.terminalLabel} /> : null}
  129. </div>
  130. <section className="walk-story-routes" aria-label={`${group.title} 的游走路线`}>
  131. <div className="walk-story-routes-label">{group.kind === "video" ? "从这条视频继续往外找" : "从这个搜索词继续往外找"}</div>
  132. {routeActions.length ? routeActions.map((action, actionIndex) => (
  133. <WalkRouteCard
  134. action={action}
  135. runId={runId}
  136. sourceId={group.id}
  137. tree={tree}
  138. level={level}
  139. seen={nextSeen}
  140. key={`${text(action.id, "walk")}-${actionIndex}`}
  141. />
  142. )) : <div className="walk-no-route">{group.kind === "video" ? "这条视频没有继续产生标签、作者或翻页路线。" : "这个搜索词没有继续翻页或产生新路线。"}</div>}
  143. </section>
  144. </article>
  145. );
  146. }
  147. function Fact({ label, value, tone = "plain" }: { label: string; value: string; tone?: string }) {
  148. return (
  149. <div className={`walk-fact ${tone}`}>
  150. <span>{label}</span>
  151. <strong>{value || "待确认"}</strong>
  152. </div>
  153. );
  154. }
  155. function WalkRouteCard({
  156. action,
  157. runId,
  158. sourceId,
  159. tree,
  160. level,
  161. seen
  162. }: {
  163. action: RawRecord;
  164. runId: string;
  165. sourceId: string;
  166. tree: WalkTree;
  167. level: number;
  168. seen: Set<string>;
  169. }) {
  170. const edgeId = text(action.edge_id, "");
  171. const targetQuery = asRecord(action.target_query);
  172. const targetVideos = Array.isArray(action.target_videos) ? action.target_videos.filter(isRecord) : [];
  173. const status = text(action.status, "");
  174. const isSuccess = status === "success";
  175. const routeName = routeLabel(edgeId);
  176. const gate = gateText(action, isSuccess);
  177. const firstNode = routeTriggerNode(action);
  178. const secondNode = routeResultNode(action, targetQuery, targetVideos, isSuccess);
  179. const childGroups = (tree.childrenByActionId.get(actionKey(action)) || []).filter((group) => !seen.has(group.id));
  180. return (
  181. <article className={`walk-tree-route ${edgeTone(edgeId)} ${isSuccess ? "success" : "stopped"}`} id={`route-${encodeURIComponent(text(action.id, routeName))}`}>
  182. <div className="walk-tree-route-dot" />
  183. <div className="walk-tree-route-body">
  184. <div className="walk-route-head">
  185. <span className="walk-edge-icon">{edgeIcon(edgeId)}</span>
  186. <div>
  187. <strong>{routeName}</strong>
  188. <p>{isSuccess ? successSentence(action, targetQuery, targetVideos) : stoppedSentence(action)}</p>
  189. </div>
  190. <span className={`chip ${isSuccess ? "green" : "yellow"}`}>{isSuccess ? "已执行" : "未执行"}</span>
  191. </div>
  192. <div className="walk-flow" aria-label={`${routeName} 的链路`}>
  193. <FlowNode label="从" value={text(action.from_label, "").includes("搜索词") ? "这个搜索词" : "这条视频"} href={`#source-${encodeURIComponent(sourceId)}`} />
  194. <FlowArrow />
  195. <FlowNode label={firstNode.label} value={firstNode.value} />
  196. <FlowArrow />
  197. <FlowNode label={secondNode.label} value={secondNode.value} href={secondNode.href} tone={isSuccess ? "strong" : "muted"} />
  198. </div>
  199. <div className="walk-route-bottom">
  200. <span className={`walk-gate ${isSuccess ? "pass" : "stop"}`}>{gate}</span>
  201. {targetQuery.id ? (
  202. <Link className="walk-jump-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id, ""))}/videos`}>
  203. 查看带回视频
  204. </Link>
  205. ) : null}
  206. <a className="walk-jump-button" href={`#source-${encodeURIComponent(sourceId)}`}>回到起点</a>
  207. </div>
  208. {targetVideos.length ? (
  209. <div className="walk-result-videos">
  210. <div className="walk-result-title">
  211. <ListVideo size={15} />
  212. <span>带回视频 {targetVideos.length} 条</span>
  213. </div>
  214. {targetVideos.slice(0, 4).map((video) => (
  215. <div className="walk-result-video" key={text(video.id, text(video.title, "video"))}>
  216. <Link className="walk-result-video-main" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
  217. <b>{text(video.title, "无标题视频")}</b>
  218. <small>{text(video.author, "未知作者")} · {text(asRecord(video.decision).label, "未判断")}</small>
  219. </Link>
  220. <VideoLinks video={video} runId={runId} compact />
  221. </div>
  222. ))}
  223. </div>
  224. ) : null}
  225. {childGroups.length ? (
  226. <div className="walk-nested-children">
  227. <div className="walk-nested-label">带回的视频继续触发下一层游走</div>
  228. {childGroups.map((group, childIndex) => (
  229. <WalkGroupCard
  230. group={group}
  231. index={childIndex}
  232. runId={runId}
  233. tree={tree}
  234. level={level + 1}
  235. seen={seen}
  236. key={`${text(action.id, "walk")}-${group.id}`}
  237. />
  238. ))}
  239. </div>
  240. ) : null}
  241. </div>
  242. </article>
  243. );
  244. }
  245. function FlowNode({ label, value, href, tone = "plain" }: { label: string; value: string; href?: string; tone?: string }) {
  246. const content = (
  247. <>
  248. <span>{label}</span>
  249. <strong>{value}</strong>
  250. </>
  251. );
  252. if (href) {
  253. return <Link className={`walk-flow-node ${tone}`} href={href}>{content}</Link>;
  254. }
  255. return <div className={`walk-flow-node ${tone}`}>{content}</div>;
  256. }
  257. function FlowArrow() {
  258. return (
  259. <span className="walk-flow-arrow" aria-hidden="true">
  260. <ArrowRight size={15} />
  261. </span>
  262. );
  263. }
  264. function buildWalkTree(actions: RawRecord[]): WalkTree {
  265. const groups = buildWalkGroups(actions);
  266. const byId = new Map(groups.map((group) => [group.id, group]));
  267. const childrenByActionId = new Map<string, WalkGroup[]>();
  268. const childIds = new Set<string>();
  269. groups.forEach((group) => {
  270. group.actions.filter((action) => !isTerminalEdge(text(action.edge_id, ""))).forEach((action) => {
  271. const targetVideos = Array.isArray(action.target_videos) ? action.target_videos.filter(isRecord) : [];
  272. const children = targetVideos
  273. .map((video) => byId.get(text(video.id, "")))
  274. .filter((child): child is WalkGroup => child !== undefined && child.id !== group.id);
  275. if (!children.length) return;
  276. const uniqueChildren = [...new Map(children.map((child) => [child.id, child])).values()];
  277. childrenByActionId.set(actionKey(action), uniqueChildren);
  278. uniqueChildren.forEach((child) => childIds.add(child.id));
  279. });
  280. });
  281. const roots = groups.filter((group) => !childIds.has(group.id));
  282. return {
  283. roots: roots.length ? roots : groups,
  284. childrenByActionId,
  285. totalGroups: groups.length
  286. };
  287. }
  288. function buildWalkGroups(actions: RawRecord[]): WalkGroup[] {
  289. const groups = new Map<string, WalkGroup>();
  290. actions.forEach((action, index) => {
  291. const video = asRecord(action.source_video);
  292. const terminal = isTerminalEdge(text(action.edge_id, ""));
  293. const id = text(video.id) || `search-${text(action.from_label, "root")}-${index}`;
  294. if (!groups.has(id)) {
  295. const decision = asRecord(video.decision);
  296. groups.set(id, {
  297. id,
  298. kind: video.id ? "video" : "query",
  299. video: video.id ? video : undefined,
  300. title: video.id ? text(video.title, "无标题视频") : queryGroupTitle(action),
  301. author: video.id ? text(video.author, "未知作者") : "搜索词级扩展",
  302. decisionLabel: text(decision.label, "未判断"),
  303. terminalLabel: terminal ? terminalResultText(action) : "",
  304. actions: []
  305. });
  306. }
  307. const group = groups.get(id)!;
  308. if (terminal && !group.terminalLabel) group.terminalLabel = terminalResultText(action);
  309. group.actions.push(action);
  310. });
  311. return [...groups.values()];
  312. }
  313. function actionKey(action: RawRecord): string {
  314. return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.from_label, "")}-${text(action.result, "")}`);
  315. }
  316. function queryGroupTitle(action: RawRecord): string {
  317. const from = cleanBusinessText(text(action.from_label, ""));
  318. if (from) return from.replace(/^从搜索词/, "搜索词").replace(/继续$/, "");
  319. return "当前搜索词";
  320. }
  321. function sourceKicker(group: WalkGroup, index: number, level: number): string {
  322. if (level) return `第 ${level + 1} 层起点`;
  323. return group.kind === "video" ? `起点视频 ${index + 1}` : "起点搜索词";
  324. }
  325. function VideoLinks({ video, runId, compact = false }: { video?: RawRecord; runId: string; compact?: boolean }) {
  326. if (!video || !video.id) return null;
  327. const platformUrl = text(video.platform_url, "");
  328. return (
  329. <div className={compact ? "walk-video-links compact" : "walk-video-links"}>
  330. <Link href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
  331. 看详情
  332. </Link>
  333. {platformUrl ? (
  334. <a href={platformUrl} target="_blank" rel="noreferrer">
  335. <ExternalLink size={12} />
  336. 抖音
  337. </a>
  338. ) : null}
  339. </div>
  340. );
  341. }
  342. function actionDepth(action: RawRecord): number {
  343. const value = Number(action.depth || 0);
  344. return Number.isFinite(value) ? value : 0;
  345. }
  346. function splitTitleAndTags(value: string): { title: string; tags: string[] } {
  347. const matches = value.match(/#[^\s#]+/g) || [];
  348. const title = value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim();
  349. return {
  350. title: title || value,
  351. tags: matches.slice(0, 6)
  352. };
  353. }
  354. function scoreSummary(value: unknown): string {
  355. const items = Array.isArray(value) ? value.filter(isRecord) : [];
  356. const visible = items.filter((item) => text(item.score_label));
  357. if (!visible.length) return "";
  358. return visible.slice(0, 3).map((item) => `${shortScoreLabel(text(item.label, "评分"))} ${text(item.score_label)}`).join(" / ");
  359. }
  360. function gateText(action: RawRecord, isSuccess: boolean): string {
  361. const score = scoreSummary(action.score_items);
  362. if (isSuccess) return score ? `通过:${firstScore(score)}` : "通过";
  363. const reason = cleanBusinessText(text(action.reason_label, ""));
  364. if (reason.includes("游走次数上限")) return "未执行:达到游走次数上限";
  365. if (reason.includes("复看")) return "未执行:待复看,需人工确认";
  366. if (reason.includes("不适合继续向外扩展")) return `未执行:${reason.replace(/^不适合继续向外扩展:?/, "") || "不适合继续向外扩展"}`;
  367. return `未执行:${reason || "没有继续扩展"}`;
  368. }
  369. function firstScore(value: string): string {
  370. return value.split("/")[0]?.trim() || value;
  371. }
  372. function terminalResultText(action: RawRecord): string {
  373. const edgeId = text(action.edge_id, "");
  374. if (edgeId === "decision_to_asset") return "入池";
  375. if (edgeId === "budget_downgrade") return "待复看";
  376. if (edgeId === "path_stop") return "淘汰并停止";
  377. return cleanBusinessText(text(action.result, "已记录"));
  378. }
  379. function isTerminalEdge(edgeId: string): boolean {
  380. return edgeId === "decision_to_asset" || edgeId === "budget_downgrade" || edgeId === "path_stop";
  381. }
  382. function routeLabel(edgeId: string): string {
  383. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "从视频标签继续游走";
  384. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "从作者继续游走";
  385. if (edgeId === "query_next_page") return "翻页继续游走";
  386. return "继续游走";
  387. }
  388. function routeTriggerNode(action: RawRecord): { label: string; value: string } {
  389. const edgeId = text(action.edge_id, "");
  390. const raw = cleanBusinessText(text(action.trigger_label, ""));
  391. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
  392. const value = raw.replace(/^候选标签:/, "").replace(/^标签搜索词:/, "#").replace(/^标签:/, "");
  393. return { label: raw.startsWith("候选标签") ? "候选标签" : "视频标签", value: value || "标签待确认" };
  394. }
  395. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
  396. return { label: "作者", value: raw.replace(/^作者:/, "") || "作者待确认" };
  397. }
  398. if (edgeId === "query_next_page") {
  399. return { label: "原搜索", value: raw.replace(/^翻下一页:/, "") || "下一页" };
  400. }
  401. return { label: "触发", value: raw || "当前内容" };
  402. }
  403. function routeResultNode(
  404. action: RawRecord,
  405. targetQuery: RawRecord,
  406. targetVideos: RawRecord[],
  407. isSuccess: boolean
  408. ): { label: string; value: string; href?: string } {
  409. const edgeId = text(action.edge_id, "");
  410. if (!isSuccess) return { label: "停止", value: "没有继续带回新内容" };
  411. if (targetQuery.id) {
  412. const textValue = text(targetQuery.text, "搜索词待确认");
  413. const label = edgeId === "query_next_page" ? "下一页" : "新搜索词";
  414. return { label, value: textValue };
  415. }
  416. if (targetVideos.length) return { label: "带回", value: `${targetVideos.length} 条视频` };
  417. return { label: "完成", value: cleanBusinessText(text(action.result, "已执行")) };
  418. }
  419. function successSentence(action: RawRecord, targetQuery: RawRecord, targetVideos: RawRecord[]): string {
  420. const edgeId = text(action.edge_id, "");
  421. if (edgeId === "query_next_page") {
  422. const from = cleanBusinessText(text(action.from_label, "当前搜索词")).replace(/继续$/, "");
  423. return `${from}翻到下一页,带回 ${targetVideos.length} 条视频。`;
  424. }
  425. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
  426. const queryText = text(targetQuery.text, "");
  427. return queryText ? `用标签生成搜索词《${queryText}》,带回 ${targetVideos.length} 条视频。` : `从标签继续搜,带回 ${targetVideos.length} 条视频。`;
  428. }
  429. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
  430. return `查看作者更多作品,带回 ${targetVideos.length} 条视频。`;
  431. }
  432. return cleanBusinessText(text(action.result, "已执行"));
  433. }
  434. function stoppedSentence(action: RawRecord): string {
  435. return gateText(action, false);
  436. }
  437. function shortScoreLabel(label: string): string {
  438. if (label === "是否契合需求") return "需求";
  439. if (label === "平台表现") return "平台";
  440. return label;
  441. }
  442. function cleanBusinessText(value: string): string {
  443. if (!value || value === "系统原因待补充" || value === "这一步没有记录额外原因") return "";
  444. return value.replaceAll("“", "《").replaceAll("”", "》");
  445. }
  446. function decisionTone(value: string): string {
  447. if (value === "入池") return "good";
  448. if (value === "待复看") return "warn";
  449. if (value === "淘汰") return "bad";
  450. return "plain";
  451. }
  452. function edgeTone(edgeId: string): string {
  453. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "tag";
  454. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "author";
  455. if (edgeId === "query_next_page") return "page";
  456. return "stop";
  457. }
  458. function edgeIcon(edgeId: string) {
  459. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return <Hash size={15} />;
  460. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return <UserSearch size={15} />;
  461. return <Search size={15} />;
  462. }
  463. function isRecord(value: unknown): value is RawRecord {
  464. return typeof value === "object" && value !== null;
  465. }