"use client"; import Link from "next/link"; import { ChevronDown, ChevronRight, ExternalLink, Hash, RotateCw, Search, UserSearch } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer"; import { EmptyState } from "@/components/StateBlocks"; import type { RawRecord } from "@/lib/api/types"; import type { BusinessTone } from "@/lib/flow-ledger/business"; import { asArray, asRecord, maybeText, text } from "@/lib/flow-ledger/format"; // --------------------------------------------------------------------------- // tree model // --------------------------------------------------------------------------- export type RouteNode = { key: string; action: RawRecord; videos: VideoNode[] }; export type VideoNode = { key: string; id: string; searchQueryId: string; batchKind: string; pageNumber: number; rankInPage: number; video: RawRecord; title: string; author: string; decisionLabel: string; decisionAction: string; score: string; tags: string[]; routes: RouteNode[]; }; export type Forest = { rootVideos: VideoNode[]; queryRoutes: RouteNode[] }; type BatchGroup = { kind: string; label: string; gate: string; videos: VideoNode[] }; type QueryGroup = { id: string; label: string; batches: BatchGroup[] }; // --------------------------------------------------------------------------- // component // --------------------------------------------------------------------------- export function WalkTree({ actions, runId, groupByQuery = false, queryLabels = {}, emptyLabel = "本轮没有继续向外扩展" }: { actions: RawRecord[]; runId: string; groupByQuery?: boolean; queryLabels?: Record; emptyLabel?: string; }) { const [drawer, setDrawer] = useState(null); const [expanded, setExpanded] = useState>(new Set()); const safeActions = useMemo(() => actions.filter(isRecord), [actions]); const forest = useMemo(() => buildForest(safeActions), [safeActions]); const keyBuckets = useMemo(() => collectKeys(forest), [forest]); // default: show every video with its routes (the flow), brought-back videos collapsed. useEffect(() => { setExpanded(new Set(keyBuckets.videoKeys)); }, [keyBuckets]); const toggle = useCallback((key: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const setDepth = useCallback( (mode: "collapse" | "routes" | "all") => { if (mode === "collapse") setExpanded(new Set()); else if (mode === "routes") setExpanded(new Set(keyBuckets.videoKeys)); else setExpanded(new Set(keyBuckets.allKeys)); }, [keyBuckets] ); const hasTree = forest.rootVideos.length > 0 || forest.queryRoutes.length > 0; const groups = useMemo( () => (groupByQuery ? groupRoots(forest.rootVideos, queryLabels) : null), [groupByQuery, forest.rootVideos, queryLabels] ); if (!hasTree) return ; return (
展开
{groups ? groups.map((group) => (
搜索词 {group.label}
{group.batches.map((batch) => (
{batch.label} {batch.videos.length} 条 {batch.gate ? · {batch.gate} : null}
{batch.videos.map((node) => ( ))}
))}
)) : forest.rootVideos.map((node, index) => ( ))} {forest.queryRoutes.map((node) => ( ))}
setDrawer(null)} />
); } // --------------------------------------------------------------------------- // rows // --------------------------------------------------------------------------- function VideoRow({ node, depth, index, hideRootIndex, runId, expanded, onToggle, onOpenDrawer }: { node: VideoNode; depth: number; index?: number; hideRootIndex?: boolean; runId: string; expanded: Set; onToggle: (key: string) => void; onOpenDrawer: (drawer: BusinessDrawerState) => void; }) { const open = expanded.has(node.key); const hasRoutes = node.routes.length > 0; const walked = node.routes.filter((r) => isWalked(r.action)).length; const tone = decisionTone(node.decisionLabel); return (
onToggle(node.key)} />
{node.author} {node.score ? {node.score} : null} {node.decisionLabel}
{hasRoutes ? ( {walked ? `${walked}/${node.routes.length} 路线已游走` : `${node.routes.length} 路线未游走`} ) : ( 无路线 )}
{open && hasRoutes ? (
{node.routes.map((route) => ( ))}
) : null}
); } function RouteRow({ node, depth, runId, expanded, onToggle, onOpenDrawer }: { node: RouteNode; depth: number; runId: string; expanded: Set; onToggle: (key: string) => void; onOpenDrawer: (drawer: BusinessDrawerState) => void; }) { const open = expanded.has(node.key); const walked = isWalked(node.action); const hasVideos = node.videos.length > 0; const edge = text(node.action.edge_id, ""); const targetQuery = asRecord(node.action.target_query); return (
onToggle(node.key)} /> {hasVideos ? 游走到 {node.videos.length} 条视频 : null} {targetQuery.id ? ( e.stopPropagation()} > 这些视频 ) : null}
{open && hasVideos ? (
{node.videos.map((video) => ( ))}
) : null}
); } function Toggle({ open, disabled, onClick }: { open: boolean; disabled: boolean; onClick: () => void }) { if (disabled) return ; return ( ); } function RowLinks({ video, runId }: { video: RawRecord; runId: string }) { const detailHref = videoDetailHref(runId, video); if (!detailHref) return null; const platformUrl = text(video.platform_url, ""); const platformLabel = text(video.platform_label, "原视频"); return ( e.stopPropagation()}> 详情 {platformUrl ? ( e.stopPropagation()}> {platformLabel} ) : null} ); } function VideoTitleLink({ runId, video, title }: { runId: string; video: RawRecord; title: string }) { const detailHref = videoDetailHref(runId, video); if (!detailHref) return {title}; return ( {title} ); } function videoDetailHref(runId: string, video: RawRecord): string { // platform content ids (视频号 finder ids) can contain / + = which break path routing; // prefer the clean content_discovery_id for the detail route. const detailId = maybeText(video.content_discovery_id) || text(video.id, ""); return detailId ? `/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(detailId)}` : ""; } // --------------------------------------------------------------------------- // build // --------------------------------------------------------------------------- export function buildForest(actions: RawRecord[]): Forest { const routable = actions.filter((a) => !isTerminalEdge(text(a.edge_id, ""))); const routesBySource = new Map(); const sourceIds = new Set(); const targetIds = new Set(); const queryRouteActions: RawRecord[] = []; routable.forEach((action) => { const sourceVideo = asRecord(action.source_video); const sid = text(sourceVideo.id, ""); if (sid) { sourceIds.add(sid); const bucket = routesBySource.get(sid) || []; bucket.push(action); routesBySource.set(sid, bucket); } else { queryRouteActions.push(action); } asArray(action.target_videos) .filter(isRecord) .forEach((v) => { const vid = text(v.id, ""); if (vid) targetIds.add(vid); }); }); const sourceVideoById = new Map(); routable.forEach((action) => { const sv = asRecord(action.source_video); const sid = text(sv.id, ""); if (sid && !sourceVideoById.has(sid)) sourceVideoById.set(sid, sv); }); const buildRoute = (action: RawRecord, parentKey: string, seen: Set): RouteNode => { const key = `${parentKey}>r:${actionKey(action)}`; const videos = asArray(action.target_videos) .filter(isRecord) .map((v) => buildVideo(v, key, routesBySource, seen)) .filter((v): v is VideoNode => v !== null); return { key, action, videos }; }; function buildVideo(video: RawRecord, parentKey: string, routes: Map, seen: Set): VideoNode | null { const id = text(video.id, ""); const key = `${parentKey}>v:${id || Math.random().toString(36).slice(2)}`; const decision = asRecord(video.decision); const childRoutes = id && !seen.has(id) ? routes.get(id) || [] : []; const nextSeen = new Set(seen); if (id) nextSeen.add(id); return { key, id, searchQueryId: text(video.search_query_id, ""), batchKind: text(video.progressive_batch_kind, ""), pageNumber: Number(video.progressive_page_number) || 0, rankInPage: Number(video.progressive_item_rank_in_page) || 0, video, title: cleanTitle(text(video.title, "无标题视频")), author: text(video.author, "未知作者"), decisionLabel: text(decision.label, "未判断"), decisionAction: text(decision.action, ""), score: scoreText(decision), tags: asArray(video.tags).map((t) => text(t, "")).filter(Boolean), routes: childRoutes.map((a) => buildRoute(a, key, nextSeen)) }; } const rootVideos: VideoNode[] = [...sourceIds] .filter((id) => !targetIds.has(id)) .map((id) => buildVideo(sourceVideoById.get(id) || { id }, "root", routesBySource, new Set())) .filter((v): v is VideoNode => v !== null); const queryRoutes = queryRouteActions.map((a) => buildRoute(a, "root", new Set())); return { rootVideos, queryRoutes }; } function groupRoots(rootVideos: VideoNode[], queryLabels: Record): QueryGroup[] { const map = new Map(); rootVideos.forEach((node) => { const qid = node.searchQueryId || ""; if (!map.has(qid)) map.set(qid, []); map.get(qid)!.push(node); }); return [...map.entries()].map(([id, videos]) => ({ id, label: queryLabels[id] || (id ? id : "其它来源"), batches: groupBatches(videos) })); } function groupBatches(videos: VideoNode[]): BatchGroup[] { const map = new Map(); videos.forEach((node) => { const kind = node.batchKind || ""; if (!map.has(kind)) map.set(kind, []); map.get(kind)!.push(node); }); return [...map.entries()] .map(([kind, items]) => ({ kind, label: batchLabel(kind), gate: batchGate(kind), videos: items.slice().sort((a, b) => a.rankInPage - b.rankInPage), sortKey: batchSortKey(kind) })) .sort((a, b) => a.sortKey - b.sortKey) .map(({ sortKey: _sortKey, ...rest }) => rest); } function batchSortKey(kind: string): number { if (kind === "initial_top3") return 10; if (kind === "page_remainder") return 11; const page = /^page_(\d+)$/.exec(kind); if (page) return Number(page[1]) * 10; if (kind === "author_works") return 9000; return 9999; } function batchLabel(kind: string): string { if (kind === "initial_top3") return "前 3 条"; if (kind === "page_remainder") return "同页剩余"; const page = /^page_(\d+)$/.exec(kind); if (page) return `第 ${Number(page[1])} 页`; if (kind === "author_works") return "作者作品"; return "未分批"; } function batchGate(kind: string): string { if (kind === "initial_top3") return "总是看"; if (kind === "page_remainder") return "命中才看"; if (/^page_\d+$/.test(kind)) return "命中才翻"; return ""; } function collectKeys(forest: Forest): { videoKeys: Set; routeKeys: Set; allKeys: Set } { const videoKeys = new Set(); const routeKeys = new Set(); const walkVideo = (node: VideoNode) => { if (node.routes.length) videoKeys.add(node.key); node.routes.forEach(walkRoute); }; const walkRoute = (node: RouteNode) => { if (node.videos.length) routeKeys.add(node.key); node.videos.forEach(walkVideo); }; forest.rootVideos.forEach(walkVideo); forest.queryRoutes.forEach(walkRoute); return { videoKeys, routeKeys, allKeys: new Set([...videoKeys, ...routeKeys]) }; } // --------------------------------------------------------------------------- // drawers // --------------------------------------------------------------------------- function videoDrawer(node: VideoNode): BusinessDrawerState { const decision = asRecord(node.video.decision); const scoreItems = asArray(decision.score_items).filter(isRecord); const reason = text(decision.reason_label, ""); return { title: node.title, sections: [ { label: "这条视频的判断", value: `${node.decisionLabel}${reason ? `:${reason}` : ""}`, tone: drawerTone(node.decisionLabel) }, scoreItems.length ? { label: "评分明细", items: scoreItems.map((item) => `${text(item.label, "评分")}:${text(item.score_label, "未记录")}`), tone: "info" } : { label: "评分明细", value: "本条没有记录评分。", tone: "info" }, node.tags.length ? { label: "视频标签", value: node.tags.join(" "), tone: "neutral" } : { label: "作者", value: node.author, tone: "neutral" } ] }; } function routeDrawer(node: RouteNode): BusinessDrawerState { const action = node.action; const walked = isWalked(action); const detailLines = asArray(action.detail_lines).map((l) => text(l, "")).filter(Boolean); return { title: `${routeKindLabel(text(action.edge_id, ""))}`, sections: [ { label: walked ? "这一步为什么继续" : "这一步为什么停下", value: walked ? `${routeObject(action)} 通过筛选,继续带回视频。` : `${routeObject(action)} 没有继续。原因:${shortReason(action)}。`, tone: walked ? "good" : "warn" }, detailLines.length ? { label: "执行明细", items: detailLines, tone: "info" } : { label: "执行明细", value: text(action.summary, "没有更多明细。"), tone: "info" }, { label: "对流程的影响", value: text(action.impact, walked ? "带回的视频会继续送去判断。" : "这条路线停止,不再带回新视频。"), tone: "neutral" } ] }; } // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- function isRecord(value: unknown): value is RawRecord { return typeof value === "object" && value !== null; } function actionKey(action: RawRecord): string { return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.trigger_label, "")}`); } function isWalked(action: RawRecord): boolean { return text(action.status, "") === "success"; } function isTerminalEdge(edgeId: string): boolean { return edgeId === "decision_to_asset" || edgeId === "budget_downgrade" || edgeId === "path_stop"; } function cleanTitle(value: string): string { return value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim() || value; } function scoreText(decision: RawRecord): string { const label = text(decision.score_label, ""); if (label && label !== "未记录") return label; const score = Number(decision.score); return Number.isFinite(score) && score > 0 ? `${Math.round(score)} 分` : ""; } function decisionTone(label: string): string { if (label === "入池") return "good"; if (label === "待复看") return "warn"; if (label === "淘汰") return "bad"; if (label.includes("审核拦截")) return "bad"; if (label.includes("技术问题") || label.includes("重试") || label.includes("补跑")) return "tech"; return "plain"; } function drawerTone(label: string): BusinessTone { if (label === "入池") return "good"; if (label === "淘汰") return "bad"; return "warn"; } function edgeTone(edgeId: string): string { if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "tag"; if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "author"; if (edgeId === "query_next_page") return "page"; return "plain"; } function edgeIcon(edgeId: string) { if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return ; if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return ; if (edgeId === "query_next_page") return ; return ; } function routeKindLabel(edgeId: string): string { if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "从视频标签继续游走"; if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "从作者继续游走"; if (edgeId === "query_next_page") return "翻页继续找"; return "继续游走"; } function routeObject(action: RawRecord): string { const edge = text(action.edge_id, ""); const raw = cleanBusinessText(text(action.trigger_label, "")); if (edge === "query_next_page") return "翻下一页"; if (edge === "hashtag_to_query" || edge === "tag_query") { const target = asRecord(action.target_query); if (target.text && isWalked(action)) return `标签`; const candidates = raw.replace(/^候选标签:/, "").split("/")[0]?.trim(); return candidates ? `标签 ${candidates}` : "标签"; } if (edge === "author_to_works" || edge === "author_works" || edge === "video_to_author") { return `作者 ${raw.replace(/^作者:/, "") || "待确认"}`; } return raw || "继续游走"; } function shortReason(action: RawRecord): string { const reason = cleanBusinessText(text(action.reason_label, "")); // technical retry: the source video failed a technical step (Gemini/play_url/OSS), // surface the concrete cause instead of the generic sentence. if (reason.includes("重试")) { const td = asRecord(asRecord(action.source_video).technical_retry_detail); const brief = maybeText(td.brief_reason); const retry = Number(td.retry_count); const suffix = Number.isFinite(retry) && retry > 0 ? `,已重试 ${retry} 次` : ""; return brief ? `判定失败待重试:${brief}${suffix}` : "判定失败,需重试后再判断"; } if (!reason) return "未达到继续扩展要求"; if (reason.includes("游走次数上限") || reason.includes("次数上限")) return "已达游走次数上限"; if (reason.includes("复看")) return "分数在待复看区间,未达入池线(先不向外扩展)"; return reason.replace(/^不适合继续向外扩展:?/, ""); } function cleanBusinessText(value: string): string { if (!value || value === "系统原因待补充" || value === "这一步没有记录额外原因") return ""; return value.replaceAll("“", "《").replaceAll("”", "》"); }