@@ -699,7 +891,7 @@ function renderDiscoveryScreen() {
return screenShell(
"找对标",
"这里已经接入真实抖音账号列表和单账号详情。",
- `${button("刷新", "refresh-data")} ${button("聚焦当前账号", "scroll-selected")} ${button("切到生产", "goto-production", "primary")}`,
+ `${button("导入主页", "open-import-homepage")} ${button("账号分析", "analyze-selected-account")} ${button("高分分析", "analyze-top-videos")} ${button("切到生产", "goto-production", "primary")}`,
`
`
);
@@ -1023,7 +1228,7 @@ function renderProductionScreen() {
return screenShell(
"生产中心",
"这里已经接上真实任务和知识库文档,后续再继续补任务创建动作。",
- `${button("刷新", "refresh-data")} ${button("看对标", "goto-discovery")} ${button("去复盘", "goto-review", "primary")}`,
+ `${button("AI 视频", "open-ai-video")} ${button("实拍剪辑", "open-real-cut")} ${button("去复盘", "goto-review", "primary")}`,
`
@@ -1199,6 +1404,433 @@ async function createProject() {
}
}
+function rememberAction(title, summary, tone = "blue", payload = null) {
+ appState.lastAction = {
+ title,
+ summary,
+ tone,
+ payload,
+ createdAt: new Date().toISOString()
+ };
+}
+
+function extractGeneratedCopy(payload) {
+ const raw = payload?.content || payload?.text || payload?.copy || payload?.result?.content || "";
+ return brief(raw, 2400);
+}
+
+function renderLastActionCard() {
+ if (!appState.lastAction) return "";
+ return `
+
+
+
+
最近动作
+
${escapeHtml(formatDateTime(appState.lastAction.createdAt))}
+
+
${escapeHtml(appState.lastAction.title)}
+
+
+
${escapeHtml(appState.lastAction.title)}
+
${escapeHtml(appState.lastAction.summary)}
+
+
+ `;
+}
+
+function requireSelectedProject() {
+ const project = getSelectedProject();
+ if (!project) throw new Error("请先创建项目");
+ return project;
+}
+
+function requireSelectedAssistant() {
+ const assistant = getSelectedAssistant();
+ if (!assistant) throw new Error("请先创建 Agent");
+ return assistant;
+}
+
+function requireSelectedAccountRow() {
+ const account = getSelectedAccount();
+ if (!account) throw new Error("请先在“找对标”里选中一个账号");
+ return account;
+}
+
+function openImportHomepageAction() {
+ const project = requireSelectedProject();
+ const kb = getProjectKnowledgeBases(project.id)[0];
+ const assistants = getAssistantOptions(project.id);
+ openActionModal({
+ title: "导入主页并同步",
+ description: "适合抖音 / 小红书 / B站 / YouTube 主页。先建内容源,再触发同步与分析。",
+ submitLabel: "开始同步",
+ 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: "标题", placeholder: "例如:创业口播对标账号" },
+ { name: "handle", label: "账号名 / handle", placeholder: "可选" },
+ { name: "sourceUrl", label: "主页链接", type: "url", placeholder: "https://..." },
+ { name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
+ { name: "maxItems", label: "最多同步作品数", type: "number", value: 5, min: 1, max: 20 }
+ ],
+ onSubmit: async (values) => {
+ if (!values.sourceUrl?.trim()) throw new Error("请填写主页链接");
+ const projectId = values.projectId || project.id;
+ const source = 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 || "主页对标",
+ metadata: {}
+ }
+ });
+ 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 || "",
+ source_url: values.sourceUrl.trim(),
+ title: values.title || values.handle || "主页对标",
+ max_items: Number(values.maxItems || 5),
+ skip_existing: true,
+ auto_trigger_analysis: true
+ }
+ });
+ rememberAction("主页同步已启动", `已把主页加入项目,并创建同步任务 ${job.title || job.id}。`, "blue", job);
+ await bootstrap();
+ }
+ });
+}
+
+function openImportVideoLinkAction() {
+ const project = requireSelectedProject();
+ const assistants = getAssistantOptions(project.id);
+ openActionModal({
+ title: "导入作品链接",
+ description: "直接把单条视频链接送进分析链。",
+ submitLabel: "开始分析",
+ fields: [
+ { name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
+ { name: "title", label: "标题", placeholder: "可选,不填则使用默认标题" },
+ { name: "videoUrl", label: "作品链接", type: "url", placeholder: "https://..." },
+ { name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
+ { name: "language", label: "语言", type: "select", value: "auto", options: [{ value: "auto", label: "自动" }, { value: "zh-CN", label: "中文" }] }
+ ],
+ onSubmit: async (values) => {
+ if (!values.videoUrl?.trim()) throw new Error("请填写作品链接");
+ const projectId = values.projectId || project.id;
+ const job = await storyforgeFetch("/v2/explore/video-link", {
+ method: "POST",
+ body: {
+ video_url: values.videoUrl.trim(),
+ title: values.title || "",
+ project_id: projectId,
+ knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || "",
+ assistant_id: values.assistantId || "",
+ language: values.language || "auto"
+ }
+ });
+ rememberAction("作品分析已启动", `已创建分析任务 ${job.title || job.id}。`, "blue", job);
+ await bootstrap();
+ }
+ });
+}
+
+function openImportTextAction() {
+ const project = requireSelectedProject();
+ const assistants = getAssistantOptions(project.id);
+ openActionModal({
+ title: "导入文本素材",
+ description: "把口播稿、拆解稿或灵感文本直接送进知识与分析链。",
+ submitLabel: "开始分析",
+ fields: [
+ { name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
+ { name: "title", label: "标题", placeholder: "例如:创业口播拆解" },
+ { name: "content", label: "正文", type: "textarea", rows: 8, placeholder: "粘贴需要分析的文本" },
+ { name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] }
+ ],
+ onSubmit: async (values) => {
+ if (!values.title?.trim()) throw new Error("请填写标题");
+ if (!values.content?.trim()) throw new Error("请填写正文");
+ const projectId = values.projectId || project.id;
+ const job = await storyforgeFetch("/v2/explore/text", {
+ method: "POST",
+ body: {
+ title: values.title.trim(),
+ content: values.content.trim(),
+ project_id: projectId,
+ knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || "",
+ assistant_id: values.assistantId || ""
+ }
+ });
+ rememberAction("文本分析已启动", `已创建文本分析任务 ${job.title || job.id}。`, "blue", job);
+ await bootstrap();
+ }
+ });
+}
+
+function openUploadVideoAction() {
+ const project = requireSelectedProject();
+ const assistants = getAssistantOptions(project.id);
+ openActionModal({
+ title: "上传本地视频",
+ description: "上传本地素材,直接进入分析链。",
+ submitLabel: "上传并分析",
+ fields: [
+ { name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
+ { name: "title", label: "标题", placeholder: "可选,不填则用文件名" },
+ { name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
+ { name: "file", label: "本地视频", type: "file", accept: ".mp4,.mov,.m4v,.avi,.mkv,.webm" }
+ ],
+ onSubmit: async (values) => {
+ if (!values.file) throw new Error("请先选择本地视频");
+ const projectId = values.projectId || project.id;
+ const form = new FormData();
+ form.append("file", values.file);
+ form.append("title", values.title || "");
+ form.append("project_id", projectId);
+ form.append("knowledge_base_id", getProjectKnowledgeBases(projectId)[0]?.id || "");
+ form.append("assistant_id", values.assistantId || "");
+ const job = await storyforgeFetch("/v2/explore/upload-video", {
+ method: "POST",
+ body: form
+ });
+ rememberAction("上传分析已启动", `已上传素材并创建任务 ${job.title || job.id}。`, "blue", job);
+ await bootstrap();
+ }
+ });
+}
+
+function openCreateAssistantAction() {
+ const project = requireSelectedProject();
+ const kbOptions = getKnowledgeBaseOptions(project.id);
+ const modelOptions = getModelOptions();
+ openActionModal({
+ title: "创建 Agent",
+ description: "先定义用途、平台与目标,再让 Agent 学习内容。",
+ submitLabel: "创建 Agent",
+ fields: [
+ { name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
+ { name: "name", label: "名称", placeholder: "例如:创业成交助手" },
+ { name: "description", label: "说明", placeholder: "例如:服务创业 IP 与成交型短视频" },
+ { name: "goal", label: "生成目标", placeholder: "例如:输出创业口播、对标拆解和成交文案" },
+ { name: "systemPrompt", label: "系统提示词", type: "textarea", rows: 5, placeholder: "可选,不填则后续再补" },
+ { name: "knowledgeBaseId", label: "默认知识库", type: "select", value: kbOptions[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...kbOptions] },
+ { name: "modelProfileId", label: "主模型", type: "select", value: modelOptions.find((item) => item.value === safeArray(appState.dashboard?.model_profiles).find((m) => m.is_default)?.id)?.value || modelOptions[0]?.value || "", options: modelOptions }
+ ],
+ onSubmit: async (values) => {
+ if (!values.name?.trim()) throw new Error("请填写 Agent 名称");
+ const projectId = values.projectId || project.id;
+ const assistant = await storyforgeFetch("/v2/assistants", {
+ method: "POST",
+ body: {
+ project_id: projectId,
+ name: values.name.trim(),
+ description: values.description || "",
+ generation_goal: values.goal || "",
+ system_prompt: values.systemPrompt || "",
+ knowledge_base_ids: values.knowledgeBaseId ? [values.knowledgeBaseId] : [],
+ model_profile_id: values.modelProfileId || ""
+ }
+ });
+ appState.selectedAssistantId = assistant.id;
+ rememberAction("Agent 已创建", `已创建 Agent「${assistant.name}」。`, "green", assistant);
+ await bootstrap();
+ }
+ });
+}
+
+function openAnalyzeSelectedAccountAction() {
+ const account = requireSelectedAccountRow();
+ openActionModal({
+ title: "分析当前对标账号",
+ description: "从商业化和内容运营角度重跑一次账号分析。",
+ submitLabel: "开始分析",
+ fields: [
+ { name: "maxVideos", label: "纳入分析作品数", type: "number", value: 6, min: 3, max: 20 },
+ { name: "extraFocus", label: "额外关注点", type: "textarea", rows: 4, placeholder: "例如:更关注商业化承接与私域转化" },
+ { name: "autoAnalyzeTopVideos", label: "分析后自动补高分作品", type: "checkbox", value: true },
+ { name: "topVideoCount", label: "高分作品分析数", type: "number", value: 4, min: 1, max: 10 }
+ ],
+ onSubmit: async (values) => {
+ const result = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/analysis`, {
+ method: "POST",
+ body: {
+ model_profile_ids: [],
+ linked_account_ids: [],
+ include_linked_accounts: true,
+ include_recent_similar_candidates: true,
+ max_videos: Number(values.maxVideos || 6),
+ extra_focus: values.extraFocus || "",
+ temperature: 0.35,
+ auto_analyze_top_videos: Boolean(values.autoAnalyzeTopVideos),
+ top_video_analysis_count: Number(values.topVideoCount || 4)
+ }
+ });
+ const summary = result?.suggestions?.[0]?.parsed_json?.executive_summary || result?.suggestions?.[0]?.suggestion_text || "已生成新的账号分析。";
+ rememberAction("对标账号分析完成", brief(summary, 120), "green", result);
+ await loadDouyinAccount(account.id);
+ renderAll();
+ }
+ });
+}
+
+function openAnalyzeTopVideosAction() {
+ const account = requireSelectedAccountRow();
+ openActionModal({
+ title: "分析高分作品",
+ description: "对当前对标账号的高分作品批量补分析。",
+ submitLabel: "开始分析",
+ fields: [
+ { name: "topVideoCount", label: "分析作品数", type: "number", value: 5, min: 1, max: 12 },
+ { name: "minScore", label: "最低分阈值", type: "number", value: 45, min: 0, max: 100 }
+ ],
+ onSubmit: async (values) => {
+ const result = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/videos/analyze-top`, {
+ method: "POST",
+ body: {
+ model_profile_id: "",
+ top_video_count: Number(values.topVideoCount || 5),
+ min_score: Number(values.minScore || 45),
+ temperature: 0.25
+ }
+ });
+ rememberAction("高分作品分析完成", `已补分析 ${formatNumber(result.analyzed_count)} 条高分作品。`, "green", result);
+ await loadDouyinAccount(account.id);
+ renderAll();
+ }
+ });
+}
+
+function openGenerateCopyAction() {
+ const assistant = requireSelectedAssistant();
+ openActionModal({
+ title: "生成文案",
+ description: "用当前 Agent 和知识库生成一版短视频文案。",
+ submitLabel: "开始生成",
+ fields: [
+ { name: "brief", label: "创作需求", type: "textarea", rows: 5, placeholder: "例如:给创业者写一条 60 字内的短视频开场文案" },
+ { name: "platform", label: "平台", type: "select", value: "抖音", options: [
+ { value: "抖音", label: "抖音" },
+ { value: "小红书", label: "小红书" },
+ { value: "哔哩哔哩", label: "哔哩哔哩" },
+ { value: "YouTube", label: "YouTube" }
+ ] },
+ { name: "audience", label: "受众", value: "创业者" },
+ { name: "extraRequirements", label: "额外要求", placeholder: "例如:强结论开头,结尾带 CTA" }
+ ],
+ onSubmit: async (values) => {
+ if (!values.brief?.trim()) throw new Error("请填写创作需求");
+ const result = await storyforgeFetch(`/v2/assistants/${encodeURIComponent(assistant.id)}/generate`, {
+ method: "POST",
+ body: {
+ brief: values.brief.trim(),
+ platform: values.platform || "抖音",
+ audience: values.audience || "创业者",
+ extra_requirements: values.extraRequirements || "",
+ knowledge_base_ids: safeArray(assistant.knowledge_base_ids)
+ }
+ });
+ appState.lastGeneratedCopy = {
+ assistantId: assistant.id,
+ assistantName: assistant.name,
+ prompt: values.brief.trim(),
+ content: extractGeneratedCopy(result),
+ usedDocuments: safeArray(result.used_documents).slice(0, 3)
+ };
+ rememberAction("文案生成完成", `已用 Agent「${assistant.name}」生成一版文案。`, "green", result);
+ renderAll();
+ }
+ });
+}
+
+function openCreateAiVideoAction() {
+ const project = requireSelectedProject();
+ const assistant = getSelectedAssistant();
+ const kb = getProjectKnowledgeBases(project.id)[0];
+ 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 }
+ ],
+ onSubmit: async (values) => {
+ if (!values.title?.trim()) throw new Error("请填写任务标题");
+ if (!values.brief?.trim()) throw new Error("请填写视频 brief");
+ const job = await storyforgeFetch("/v2/pipelines/ai-video", {
+ method: "POST",
+ body: {
+ project_id: project.id,
+ assistant_id: assistant?.id || "",
+ knowledge_base_id: kb?.id || "",
+ source_job_id: values.sourceJobId || "",
+ title: values.title.trim(),
+ brief: values.brief.trim(),
+ style: values.style || "realistic",
+ shots: Number(values.shots || 4),
+ duration: Number(values.duration || 5)
+ }
+ });
+ rememberAction("AI 视频任务已创建", `已创建任务 ${job.title || job.id}。`, "blue", job);
+ await bootstrap();
+ }
+ });
+}
+
+function openCreateRealCutAction() {
+ const project = requireSelectedProject();
+ 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: "例如:保留高信息密度片段,输出适合短视频平台的粗剪结果" }
+ ],
+ onSubmit: async (values) => {
+ if (!values.title?.trim()) throw new Error("请填写任务标题");
+ if (!values.sourceJobId) throw new Error("请先选择一个已完成的源任务");
+ const job = await storyforgeFetch("/v2/pipelines/real-cut", {
+ method: "POST",
+ body: {
+ project_id: project.id,
+ title: values.title.trim(),
+ source_job_id: values.sourceJobId,
+ target_duration_sec: Number(values.targetDurationSec || 60),
+ target_aspect_ratio: values.aspectRatio || "9:16",
+ objective: values.objective || "保留高信息密度片段,输出适合短视频平台的粗剪结果"
+ }
+ });
+ rememberAction("实拍剪辑任务已创建", `已创建任务 ${job.title || job.id}。`, "blue", job);
+ await bootstrap();
+ }
+ });
+}
+
document.addEventListener("click", async (event) => {
const action = event.target.closest("[data-action]");
if (action) {
@@ -1211,6 +1843,14 @@ document.addEventListener("click", async (event) => {
closeAuthModal();
return;
}
+ if (name === "close-sheet") {
+ closeActionModal();
+ return;
+ }
+ if (name === "submit-sheet") {
+ await submitActionModal();
+ return;
+ }
if (name === "submit-auth") {
setBusy(true, "正在登录并加载...");
try {
@@ -1249,6 +1889,46 @@ document.addEventListener("click", async (event) => {
setScreen("review");
return;
}
+ if (name === "open-import-homepage") {
+ openImportHomepageAction();
+ return;
+ }
+ if (name === "open-import-video-link") {
+ openImportVideoLinkAction();
+ return;
+ }
+ if (name === "open-import-text") {
+ openImportTextAction();
+ return;
+ }
+ if (name === "open-upload-video") {
+ openUploadVideoAction();
+ return;
+ }
+ if (name === "open-create-assistant") {
+ openCreateAssistantAction();
+ return;
+ }
+ if (name === "analyze-selected-account") {
+ openAnalyzeSelectedAccountAction();
+ return;
+ }
+ if (name === "analyze-top-videos") {
+ openAnalyzeTopVideosAction();
+ return;
+ }
+ if (name === "open-generate-copy") {
+ openGenerateCopyAction();
+ return;
+ }
+ if (name === "open-ai-video") {
+ openCreateAiVideoAction();
+ return;
+ }
+ if (name === "open-real-cut") {
+ openCreateRealCutAction();
+ return;
+ }
if (name === "create-project") {
await createProject();
return;
diff --git a/web/storyforge-web-v4/assets/styles.css b/web/storyforge-web-v4/assets/styles.css
index 244d301..ff35ee9 100644
--- a/web/storyforge-web-v4/assets/styles.css
+++ b/web/storyforge-web-v4/assets/styles.css
@@ -294,7 +294,8 @@ select {
z-index: 40;
}
-.auth-modal-backdrop.hidden {
+.auth-modal-backdrop.hidden,
+.action-modal-backdrop.hidden {
display: none;
}
@@ -307,6 +308,29 @@ select {
padding: 22px;
}
+.action-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 28, 45, 0.34);
+ backdrop-filter: blur(8px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ z-index: 45;
+}
+
+.action-modal {
+ width: min(640px, 100%);
+ max-height: min(86vh, 920px);
+ overflow: auto;
+ border-radius: 24px;
+ border: 1px solid var(--line-strong);
+ background: rgba(255, 255, 255, 0.985);
+ box-shadow: var(--shadow);
+ padding: 22px;
+}
+
.auth-head {
display: flex;
align-items: start;
@@ -339,7 +363,8 @@ select {
}
.field-stack input,
-.field-stack textarea {
+.field-stack textarea,
+.field-stack select {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
@@ -349,6 +374,20 @@ select {
resize: vertical;
}
+.checkbox-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 0 2px;
+ color: var(--text);
+ font-size: 13px;
+}
+
+.checkbox-row input {
+ width: 16px;
+ height: 16px;
+}
+
.helper-text {
min-height: 18px;
color: var(--orange);