From 32dea8e3a6cdb5074abc9bfc5606cd2d4f60660d Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 22 Mar 2026 11:45:18 +0800 Subject: [PATCH] feat: extend benchmark and job action flows --- web/storyforge-web-v4/README.md | 5 +- web/storyforge-web-v4/assets/app.js | 280 +++++++++++++++++++++++++--- 2 files changed, 253 insertions(+), 32 deletions(-) diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index 3a2b93c..8d7a108 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -46,7 +46,9 @@ - 对当前 Douyin 对标账号重跑分析 - 批量分析高分作品 - 查找相似对标账号 -- 查看任务详情、事件和 artifacts/result +- 从相似候选一键保存对标关系 +- 查看任务详情、事件、子任务和 artifacts/result +- 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成 - 使用 Agent 生成文案 - 创建 AI 视频任务 - 创建实拍剪辑任务 @@ -71,6 +73,7 @@ python3 -m http.server 3918 ## 后续建议 - 继续补动作型接口,例如导入、绑定 Agent、触发分析与生产 +- 把对标导入后的 Agent 绑定和知识库入库反馈做得更完整 - 把全局搜索和页内搜索合并成统一搜索体验 - 为 `生产中心 / 发布与复盘` 接入更完整的任务与成片对象 - 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs` diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 106fecd..020b699 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -670,14 +670,124 @@ function getVideoLink(video) { } async function loadJobDetail(jobId) { - const [job, events] = await Promise.all([ + const [job, events, childJobs] = await Promise.all([ storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}`), - storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}/events`).catch(() => []) + storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}/events`).catch(() => []), + storyforgeFetch(`/v2/explore/jobs?parent_job_id=${encodeURIComponent(jobId)}`).catch(() => []) ]); - appState.lastJobDetail = { job, events: safeArray(events) }; + appState.lastJobDetail = { job, events: safeArray(events), childJobs: safeArray(childJobs) }; return appState.lastJobDetail; } +function isJobCompleted(job) { + return String(job?.status || "").toLowerCase() === "completed"; +} + +function canDeriveAiVideo(job) { + if (!job || !isJobCompleted(job)) return false; + return String(job.line_type || "").toLowerCase() !== "ai_video"; +} + +function canDeriveRealCut(job) { + if (!job || !isJobCompleted(job)) return false; + const sourceType = String(job.source_type || "").toLowerCase(); + return ["video_link", "upload_video"].includes(sourceType); +} + +function getJobSeedBrief(job) { + return [ + job?.style_summary, + job?.transcript_text, + job?.result?.summary, + job?.artifacts?.brief, + job?.title + ].find((value) => String(value || "").trim()) || ""; +} + +function collectHttpLinks(input, path = "result", bucket = []) { + if (!input) return bucket; + if (typeof input === "string") { + const value = input.trim(); + if (/^https?:\/\//i.test(value)) { + bucket.push({ label: path, url: value }); + } + return bucket; + } + if (Array.isArray(input)) { + input.forEach((item, index) => collectHttpLinks(item, `${path}[${index}]`, bucket)); + return bucket; + } + if (typeof input === "object") { + Object.entries(input).forEach(([key, value]) => collectHttpLinks(value, `${path}.${key}`, bucket)); + } + return bucket; +} + +function getJobPreviewLinks(job) { + const deduped = []; + const seen = new Set(); + collectHttpLinks(job?.result, "result", deduped); + collectHttpLinks(job?.artifacts, "artifacts", deduped); + return deduped.filter((item) => { + if (!item.url || seen.has(item.url)) return false; + seen.add(item.url); + return true; + }).slice(0, 8); +} + +function isCandidateLinked(candidate, links) { + const accountId = String(candidate?.candidate_account_id || ""); + const profileUrl = String(candidate?.candidate_profile_url || ""); + return safeArray(links).some((link) => ( + (accountId && String(link.target_account_id || "") === accountId) || + (profileUrl && String(link.target_profile_url || "") === profileUrl) + )); +} + +function markSavedCandidate(candidate, links) { + const nextCandidates = safeArray(appState.lastSimilaritySearch?.candidates).map((item) => { + const sameAccount = String(item.candidate_account_id || "") && String(item.candidate_account_id || "") === String(candidate.candidate_account_id || ""); + const sameUrl = String(item.candidate_profile_url || "") && String(item.candidate_profile_url || "") === String(candidate.candidate_profile_url || ""); + if (!sameAccount && !sameUrl) return item; + return { ...item, saved: true }; + }); + if (appState.lastSimilaritySearch) { + appState.lastSimilaritySearch = { + ...appState.lastSimilaritySearch, + candidates: nextCandidates + }; + } + if (appState.selectedWorkspace) { + appState.selectedWorkspace = { + ...appState.selectedWorkspace, + linked_accounts: safeArray(links) + }; + } +} + +async function saveCandidateAsBenchmark(candidateIndex, relationType = "benchmark") { + const account = requireSelectedAccountRow(); + const candidate = safeArray(appState.lastSimilaritySearch?.candidates)[Number(candidateIndex)]; + if (!candidate) throw new Error("当前候选不存在,请先重新查相似"); + const payload = { + target_account_ids: candidate.candidate_account_id ? [candidate.candidate_account_id] : [], + target_profile_urls: candidate.candidate_account_id ? [] : [candidate.candidate_profile_url].filter(Boolean), + relation_type: relationType, + note: brief(candidate.rationale_text || "由相似搜索自动加入对标库", 120), + search_id: appState.lastSimilaritySearch?.id || "" + }; + if (!payload.target_account_ids.length && !payload.target_profile_urls.length) { + throw new Error("当前候选没有可保存的账号或主页链接"); + } + const result = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/benchmark-links`, { + method: "POST", + body: payload + }); + markSavedCandidate(candidate, result.links); + rememberAction("候选已存对标", `已把「${candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号"}」加入对标关系。`, "green", result); + renderAll(); +} + function screenShell(title, subtitle, actionsHtml, bodyHtml) { return `
@@ -1044,7 +1154,11 @@ function renderDiscoveryScreen() {

${escapeHtml(link.target_nickname || link.target_profile_url || "未命名对标")}

${escapeHtml(link.note || link.target_profile_url || "已保存对标关系")}

-
${escapeHtml(link.relation_type || "benchmark")}
+
+ ${escapeHtml(link.relation_type || "benchmark")} + ${link.target_account_id ? `看详情` : ""} + ${link.target_profile_url ? `打开主页` : ""} +
`).join("") || `

暂无已保存对标

当前账号还没有保存过对标关系。

`}
@@ -1052,12 +1166,14 @@ function renderDiscoveryScreen() {

最近相似候选

由 Agent 辅助生成
${escapeHtml(formatNumber(similarCandidates.length))} 个
- ${similarCandidates.map((candidate) => ` + ${similarCandidates.map((candidate, index) => `

${escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号")}

${escapeHtml(brief(candidate.rationale_text || "暂无理由", 96))}

启发分 ${escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score || 0))} + ${candidate.candidate_account_id ? `看详情` : ""} + ${isCandidateLinked(candidate, linkedAccounts) || candidate.saved ? `已保存` : `存对标`} ${candidate.candidate_profile_url ? `打开主页` : ""}
@@ -1289,6 +1405,8 @@ function renderProductionScreen() {
${escapeHtml(job.status)} ${escapeHtml(job.line_type || "analysis")} + ${canDeriveAiVideo(job) ? `做 AI 视频` : ""} + ${canDeriveRealCut(job) ? `做实拍剪辑` : ""} 看详情
@@ -1343,7 +1461,13 @@ function renderReviewScreen() {

${escapeHtml(job.title)}

${escapeHtml(brief(job.style_summary || job.transcript_text || "已完成,待补复盘。", 84))}

-
已完成${escapeHtml(job.line_type || "analysis")}看详情
+
+ 已完成 + ${escapeHtml(job.line_type || "analysis")} + ${canDeriveAiVideo(job) ? `做 AI 视频` : ""} + ${canDeriveRealCut(job) ? `做实拍剪辑` : ""} + 看详情 +
`).join("") || `

还没有完成任务

先去生产中心跑一条链路。

`}
@@ -1791,38 +1915,49 @@ function openSimilaritySearchAction() { }); } -function openBenchmarkLinkAction() { +function openBenchmarkLinkAction(defaults = {}) { const account = requireSelectedAccountRow(); const options = safeArray(appState.accounts) .filter((item) => item.id !== account.id) .map((item) => ({ value: item.id, label: item.nickname || item.douyin_id || item.id })); + const candidate = typeof defaults.candidateIndex === "number" + ? safeArray(appState.lastSimilaritySearch?.candidates)[defaults.candidateIndex] || null + : null; openActionModal({ title: "保存对标关系", description: "把当前账号和另一个账号关联成对标关系,便于后续持续跟踪。", submitLabel: "保存关系", fields: [ - { name: "targetAccountId", label: "目标账号", type: "select", value: options[0]?.value || "", options }, + { name: "targetAccountId", label: "目标账号", type: "select", value: defaults.targetAccountId || candidate?.candidate_account_id || options[0]?.value || "", options: [{ value: "", label: "仅保存主页链接" }, ...options] }, + { name: "targetProfileUrl", label: "目标主页链接", type: "url", value: defaults.targetProfileUrl || candidate?.candidate_profile_url || "", placeholder: "没有本地账号时可直接保存主页链接" }, { name: "relationType", label: "关系类型", type: "select", value: "benchmark", options: [ { value: "benchmark", label: "对标" }, { value: "learn", label: "学习" }, { value: "watch", label: "跟踪" } ] }, - { name: "note", label: "备注", placeholder: "例如:开场结构很强,适合持续跟踪" } + { name: "note", label: "备注", value: defaults.note || brief(candidate?.rationale_text || "", 120), placeholder: "例如:开场结构很强,适合持续跟踪" } ], onSubmit: async (values) => { - if (!values.targetAccountId) throw new Error("请先选择一个目标账号"); - await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/benchmark-links`, { + if (!values.targetAccountId && !values.targetProfileUrl?.trim()) throw new Error("请先选择一个目标账号或填写主页链接"); + const result = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/benchmark-links`, { method: "POST", body: { - target_account_ids: [values.targetAccountId], - target_profile_urls: [], + target_account_ids: values.targetAccountId ? [values.targetAccountId] : [], + target_profile_urls: values.targetAccountId ? [] : [values.targetProfileUrl.trim()], relation_type: values.relationType || "benchmark", note: values.note || "", search_id: appState.lastSimilaritySearch?.id || "" } }); + if (candidate) { + markSavedCandidate(candidate, result.links); + } else if (appState.selectedWorkspace) { + appState.selectedWorkspace = { + ...appState.selectedWorkspace, + linked_accounts: safeArray(result.links) + }; + } rememberAction("对标关系已保存", "当前账号的对标关系已更新。", "green"); - await loadDouyinAccount(account.id); renderAll(); } }); @@ -1832,9 +1967,10 @@ function openJobDetailAction(jobId) { if (!jobId) return; setBusy(true, "正在加载任务详情..."); loadJobDetail(jobId) - .then(({ job, events }) => { + .then(({ job, events, childJobs }) => { const artifacts = JSON.stringify(job.artifacts || {}, null, 2); const result = JSON.stringify(job.result || {}, null, 2); + const previewLinks = getJobPreviewLinks(job); openActionModal({ title: job.title || "任务详情", description: `状态:${job.status || "-"} · 类型:${job.line_type || job.source_type || "-"}`, @@ -1866,6 +2002,53 @@ function openJobDetailAction(jobId) { ` }, + { + type: "html", + label: "结果预览", + html: ` +
+ ${previewLinks.map((item) => ` +
+

${escapeHtml(item.label.replace(/^result\./, "").replace(/^artifacts\./, ""))}

+

${escapeHtml(item.url)}

+ +
+ `).join("") || `

暂无外部结果链接

当前先保留 artifacts 和 result 原始数据供查看。

`} +
+ ` + }, + { + type: "html", + label: "下一步动作", + html: ` +
+ ${canDeriveAiVideo(job) ? `继续做 AI 视频` : ""} + ${canDeriveRealCut(job) ? `继续做实拍剪辑` : ""} + 用摘要写文案 +
+ ` + }, + { + type: "html", + label: "子任务", + html: ` +
+ ${safeArray(childJobs).slice(0, 6).map((item) => ` +
+

${escapeHtml(item.title || item.id)}

+

${escapeHtml(brief(item.style_summary || item.transcript_text || item.error || "暂无摘要", 96))}

+
+ ${escapeHtml(item.status || "-")} + ${escapeHtml(item.line_type || "-")} + 看详情 +
+
+ `).join("") || `

暂无子任务

当前任务还没有派生出下一层任务。

`} +
+ ` + }, { type: "textarea", name: "artifactsReadonly", label: "Artifacts", value: artifacts, rows: 8 }, { type: "textarea", name: "resultReadonly", label: "Result", value: result, rows: 8 } ] @@ -1881,14 +2064,15 @@ function openJobDetailAction(jobId) { }); } -function openGenerateCopyAction() { - const assistant = requireSelectedAssistant(); +function openGenerateCopyAction(defaults = {}) { + const assistant = getSelectedAssistant() || requireSelectedAssistant(); + const sourceJob = defaults.sourceJob || null; openActionModal({ title: "生成文案", description: "用当前 Agent 和知识库生成一版短视频文案。", submitLabel: "开始生成", fields: [ - { name: "brief", label: "创作需求", type: "textarea", rows: 5, placeholder: "例如:给创业者写一条 60 字内的短视频开场文案" }, + { name: "brief", label: "创作需求", type: "textarea", rows: 5, value: defaults.brief || getJobSeedBrief(sourceJob), placeholder: "例如:给创业者写一条 60 字内的短视频开场文案" }, { name: "platform", label: "平台", type: "select", value: "抖音", options: [ { value: "抖音", label: "抖音" }, { value: "小红书", label: "小红书" }, @@ -1923,21 +2107,22 @@ function openGenerateCopyAction() { }); } -function openCreateAiVideoAction() { +function openCreateAiVideoAction(defaults = {}) { const project = requireSelectedProject(); const assistant = getSelectedAssistant(); const kb = getProjectKnowledgeBases(project.id)[0]; + const sourceJob = defaults.sourceJob || null; openActionModal({ title: "创建 AI 视频任务", description: "输入 brief 后,直接触发 AI 视频链。", submitLabel: "开始生产", fields: [ - { name: "title", label: "任务标题", placeholder: "例如:创业口播 AI 视频测试" }, - { name: "brief", label: "视频 brief", type: "textarea", rows: 5, placeholder: "写明主题、风格、镜头和目标受众" }, - { name: "sourceJobId", label: "关联源任务", type: "select", value: "", options: [{ value: "", label: "不关联" }, ...getCompletedJobOptions()] }, - { name: "style", label: "风格", value: "realistic" }, - { name: "shots", label: "镜头数", type: "number", value: 4, min: 1, max: 12 }, - { name: "duration", label: "单镜头秒数", type: "number", value: 5, min: 3, max: 12 } + { name: "title", label: "任务标题", value: defaults.title || (sourceJob ? `${sourceJob.title} · AI 视频` : ""), placeholder: "例如:创业口播 AI 视频测试" }, + { name: "brief", label: "视频 brief", type: "textarea", rows: 5, value: defaults.brief || getJobSeedBrief(sourceJob), placeholder: "写明主题、风格、镜头和目标受众" }, + { name: "sourceJobId", label: "关联源任务", type: "select", value: defaults.sourceJobId || sourceJob?.id || "", options: [{ value: "", label: "不关联" }, ...getCompletedJobOptions()] }, + { name: "style", label: "风格", value: defaults.style || "realistic" }, + { name: "shots", label: "镜头数", type: "number", value: defaults.shots || 4, min: 1, max: 12 }, + { name: "duration", label: "单镜头秒数", type: "number", value: defaults.duration || 5, min: 3, max: 12 } ], onSubmit: async (values) => { if (!values.title?.trim()) throw new Error("请填写任务标题"); @@ -1962,18 +2147,19 @@ function openCreateAiVideoAction() { }); } -function openCreateRealCutAction() { +function openCreateRealCutAction(defaults = {}) { const project = requireSelectedProject(); + const sourceJob = defaults.sourceJob || null; openActionModal({ title: "创建实拍剪辑任务", description: "基于已完成的源任务,把素材发到 cutvideo。", submitLabel: "开始剪辑", fields: [ - { name: "title", label: "任务标题", placeholder: "例如:创业素材粗剪" }, - { name: "sourceJobId", label: "源任务", type: "select", value: getCompletedJobOptions()[0]?.value || "", options: getCompletedJobOptions() }, - { name: "targetDurationSec", label: "目标时长(秒)", type: "number", value: 60, min: 10, max: 300 }, - { name: "aspectRatio", label: "画幅", value: "9:16" }, - { name: "objective", label: "目标", type: "textarea", rows: 4, placeholder: "例如:保留高信息密度片段,输出适合短视频平台的粗剪结果" } + { name: "title", label: "任务标题", value: defaults.title || (sourceJob ? `${sourceJob.title} · 实拍剪辑` : ""), placeholder: "例如:创业素材粗剪" }, + { name: "sourceJobId", label: "源任务", type: "select", value: defaults.sourceJobId || sourceJob?.id || getCompletedJobOptions()[0]?.value || "", options: getCompletedJobOptions() }, + { name: "targetDurationSec", label: "目标时长(秒)", type: "number", value: defaults.targetDurationSec || 60, min: 10, max: 300 }, + { name: "aspectRatio", label: "画幅", value: defaults.aspectRatio || "9:16" }, + { name: "objective", label: "目标", type: "textarea", rows: 4, value: defaults.objective || "", placeholder: "例如:保留高信息密度片段,输出适合短视频平台的粗剪结果" } ], onSubmit: async (values) => { if (!values.title?.trim()) throw new Error("请填写任务标题"); @@ -2101,10 +2287,42 @@ document.addEventListener("click", async (event) => { openBenchmarkLinkAction(); return; } + if (name === "save-candidate-benchmark") { + setBusy(true, "正在保存对标关系..."); + try { + await saveCandidateAsBenchmark(action.dataset.candidateIndex || ""); + } catch (error) { + alert("保存候选失败: " + error.message); + } finally { + setBusy(false, ""); + } + return; + } if (name === "open-job-detail") { openJobDetailAction(action.dataset.jobId || ""); return; } + if (name === "job-to-ai-video") { + const jobId = action.dataset.jobId || ""; + const detail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null; + closeActionModal(); + openCreateAiVideoAction({ sourceJobId: jobId, sourceJob: detail }); + return; + } + if (name === "job-to-real-cut") { + const jobId = action.dataset.jobId || ""; + const detail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null; + closeActionModal(); + openCreateRealCutAction({ sourceJobId: jobId, sourceJob: detail, objective: detail ? `基于任务「${detail.title}」保留高信息密度片段,输出适合短视频平台的粗剪结果。` : "" }); + return; + } + if (name === "job-to-generate-copy") { + const jobId = action.dataset.jobId || ""; + const detail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null; + closeActionModal(); + openGenerateCopyAction({ sourceJob: detail, brief: detail ? `基于任务「${detail.title}」的结果,生成一版可发布的短视频文案。参考摘要:${getJobSeedBrief(detail)}` : "" }); + return; + } if (name === "create-project") { await createProject(); return;