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")}
+
`).join("") || `
`}
@@ -1052,12 +1166,14 @@ function renderDiscoveryScreen() {
${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))}
@@ -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;