diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md
index 8d7a108..0a51f0a 100644
--- a/web/storyforge-web-v4/README.md
+++ b/web/storyforge-web-v4/README.md
@@ -39,6 +39,7 @@
- 新建项目
- 导入主页并触发内容源同步
+- 把当前对标账号直接导入到当前项目,并绑定 Agent 触发同步
- 导入作品链接并触发分析
- 导入文本素材并触发分析
- 上传本地视频并触发分析
@@ -49,6 +50,7 @@
- 从相似候选一键保存对标关系
- 查看任务详情、事件、子任务和 artifacts/result
- 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成
+- 在生产中心 / 发布与复盘常驻最近一次任务详情摘要
- 使用 Agent 生成文案
- 创建 AI 视频任务
- 创建实拍剪辑任务
diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js
index 020b699..a35d3dc 100644
--- a/web/storyforge-web-v4/assets/app.js
+++ b/web/storyforge-web-v4/assets/app.js
@@ -614,6 +614,27 @@ function getProjectStats(projectId) {
return { knowledgeBases, assistants, jobs, sources };
}
+function getContentSourcesForAccount(account) {
+ if (!account) return [];
+ const profileUrl = String(account.profile_url || "").trim();
+ const douyinId = String(account.douyin_id || "").trim();
+ const nickname = String(account.nickname || "").trim();
+ return safeArray(appState.contentSources).filter((source) => {
+ const sourceUrl = String(source.source_url || "").trim();
+ const handle = String(source.handle || "").trim();
+ const title = String(source.title || "").trim();
+ return (
+ (profileUrl && sourceUrl === profileUrl) ||
+ (douyinId && handle === douyinId) ||
+ (nickname && title.includes(nickname))
+ );
+ });
+}
+
+function getCurrentProjectSourcesForAccount(account, projectId) {
+ return getContentSourcesForAccount(account).filter((source) => source.project_id === projectId);
+}
+
function getSelectedAccount() {
return appState.selectedWorkspace?.account
|| appState.accounts.find((item) => item.id === appState.selectedAccountId)
@@ -1021,10 +1042,12 @@ function renderDiscoveryScreen() {
const topVideos = getHighScoreVideos(3);
const latestVideos = getLatestVideos(2);
const similarCandidates = safeArray(appState.lastSimilaritySearch?.candidates).slice(0, 5);
+ const selectedProject = getSelectedProject();
+ const importedSources = getCurrentProjectSourcesForAccount(selected, selectedProject?.id || "");
return screenShell(
"找对标",
"这里已经接入真实抖音账号列表和单账号详情。",
- `${button("导入主页", "open-import-homepage")} ${button("账号分析", "analyze-selected-account")} ${button("高分分析", "analyze-top-videos")} ${button("查相似", "open-similar-search")} ${button("存对标", "open-benchmark-link", "primary")}`,
+ `${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "open-import-selected-account")} ${button("账号分析", "analyze-selected-account")} ${button("高分分析", "analyze-top-videos")} ${button("查相似", "open-similar-search")} ${button("存对标", "open-benchmark-link", "primary")}`,
`
+
+
接入当前项目
把当前对标导入到项目,并绑定 Agent 做持续同步
${escapeHtml(importedSources.length ? "已接入" : "未接入")}
+ ${selected ? `
+
+
${escapeHtml(selectedProject?.name || "未选项目")}
+
${escapeHtml(importedSources.length ? `当前项目已接入 ${formatNumber(importedSources.length)} 个内容源,可继续同步或换 Agent。` : "当前项目还没有接入这个对标账号,可直接导入主页并绑定 Agent。")}
+
+ ${escapeHtml(selectedProject?.name || "未选项目")}
+ ${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")}
+ ${importedSources.length ? "继续同步" : "导入当前对标"}
+
+
+ ` : `
还没有选中账号
先从左侧列表选一个对标账号,再决定是否导入到当前项目。
`}
+
账号画像
@@ -1438,6 +1475,7 @@ function renderProductionScreen() {
`).join("") || (works.length ? "" : `
`)}
+ ${renderLastJobDetailCard()}
`
@@ -1472,6 +1510,7 @@ function renderReviewScreen() {
`).join("") || ``}
+ ${renderLastJobDetailCard()}
`
);
}
@@ -1601,6 +1640,43 @@ function renderLastActionCard() {
`;
}
+function renderLastJobDetailCard() {
+ const detail = appState.lastJobDetail;
+ if (!detail?.job) return "";
+ const previewLinks = getJobPreviewLinks(detail.job);
+ return `
+
+
+
+
最近任务详情
+
${escapeHtml(formatDateTime(detail.job.created_at))}
+
+
${escapeHtml(detail.job.status || "-")}
+
+
+
${escapeHtml(detail.job.title || detail.job.id)}
+
${escapeHtml(brief(detail.job.style_summary || detail.job.transcript_text || detail.job.error || "暂无摘要", 120))}
+
+ ${escapeHtml(detail.job.line_type || "-")}
+ ${canDeriveAiVideo(detail.job) ? `做 AI 视频` : ""}
+ ${canDeriveRealCut(detail.job) ? `做实拍剪辑` : ""}
+ 看详情
+
+
+ ${previewLinks.length ? `
+
+ ${previewLinks.slice(0, 3).map((item) => `
+
+
${escapeHtml(item.label.replace(/^result\./, "").replace(/^artifacts\./, ""))}
+
${escapeHtml(item.url)}
+
+ `).join("")}
+
+ ` : ""}
+
+ `;
+}
+
function requireSelectedProject() {
const project = getSelectedProject();
if (!project) throw new Error("请先创建项目");
@@ -1680,6 +1756,79 @@ function openImportHomepageAction() {
});
}
+function openImportSelectedAccountAction() {
+ const account = requireSelectedAccountRow();
+ const project = requireSelectedProject();
+ const assistants = getAssistantOptions(project.id);
+ const currentSources = getCurrentProjectSourcesForAccount(account, project.id);
+ const currentSource = currentSources[0];
+ const kb = getProjectKnowledgeBases(project.id)[0];
+ openActionModal({
+ title: currentSource ? "继续同步当前对标" : "导入当前对标",
+ description: currentSource
+ ? "当前项目里已经有这个对标账号,继续触发同步并可切换绑定 Agent。"
+ : "把当前选中的对标账号加入项目,并绑定 Agent 进入持续同步。",
+ submitLabel: currentSource ? "继续同步" : "导入并同步",
+ fields: [
+ { name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
+ { name: "platform", label: "平台", type: "select", value: "douyin", options: [
+ { value: "douyin", label: "抖音" },
+ { value: "xiaohongshu", label: "小红书" },
+ { value: "bilibili", label: "哔哩哔哩" },
+ { value: "youtube", label: "YouTube" },
+ { value: "kuaishou", label: "快手" },
+ { value: "wechat_video", label: "微信视频号" }
+ ] },
+ { name: "title", label: "内容源标题", value: currentSource?.title || `${account.nickname || account.douyin_id || "对标账号"} 对标主页` },
+ { name: "handle", label: "账号标识", value: currentSource?.handle || account.douyin_id || "" },
+ { name: "sourceUrl", label: "主页链接", type: "url", value: currentSource?.source_url || account.profile_url || "", placeholder: "https://..." },
+ { name: "assistantId", label: "绑定 Agent", type: "select", value: getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
+ { name: "maxItems", label: "最多同步作品数", type: "number", value: Number(currentSource?.metadata?.max_items || 6), min: 1, max: 20 },
+ { name: "skipExisting", label: "跳过已存在作品", type: "checkbox", value: true },
+ { name: "autoAnalyze", label: "同步后自动分析", type: "checkbox", value: true }
+ ],
+ onSubmit: async (values) => {
+ if (!values.sourceUrl?.trim()) throw new Error("请先填写主页链接");
+ const projectId = values.projectId || project.id;
+ const source = currentSource && currentSource.project_id === projectId
+ ? currentSource
+ : await storyforgeFetch("/v2/content-sources", {
+ method: "POST",
+ body: {
+ project_id: projectId,
+ source_kind: "creator_account",
+ platform: values.platform || "douyin",
+ handle: values.handle || "",
+ source_url: values.sourceUrl.trim(),
+ title: values.title || values.handle || account.nickname || "对标主页",
+ metadata: {
+ imported_from_account_id: account.id,
+ imported_from_workspace: "discovery"
+ }
+ }
+ });
+ const job = await storyforgeFetch("/v2/pipelines/content-source-sync", {
+ method: "POST",
+ body: {
+ project_id: projectId,
+ knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || kb?.id || "",
+ assistant_id: values.assistantId || "",
+ content_source_id: source.id,
+ platform: values.platform || "douyin",
+ handle: values.handle || account.douyin_id || "",
+ source_url: values.sourceUrl.trim(),
+ title: values.title || account.nickname || values.handle || "对标主页",
+ max_items: Number(values.maxItems || 6),
+ skip_existing: Boolean(values.skipExisting),
+ auto_trigger_analysis: Boolean(values.autoAnalyze)
+ }
+ });
+ rememberAction("对标已接入项目", `已把「${account.nickname || account.douyin_id || "当前对标"}」接入项目,并创建同步任务 ${job.title || job.id}。`, "green", { source, job });
+ await bootstrap();
+ }
+ });
+}
+
function openImportVideoLinkAction() {
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
@@ -2243,6 +2392,10 @@ document.addEventListener("click", async (event) => {
openImportHomepageAction();
return;
}
+ if (name === "open-import-selected-account") {
+ openImportSelectedAccountAction();
+ return;
+ }
if (name === "open-import-video-link") {
openImportVideoLinkAction();
return;