diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index 21702fe..0920324 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -35,6 +35,20 @@ - 单账号作品列表 `/v2/douyin/accounts/{id}/videos` - 最近知识库文档 `/v2/knowledge-bases/{id}/documents` +## 当前已接入的真实动作 + +- 新建项目 +- 导入主页并触发内容源同步 +- 导入作品链接并触发分析 +- 导入文本素材并触发分析 +- 上传本地视频并触发分析 +- 创建 Agent +- 对当前 Douyin 对标账号重跑分析 +- 批量分析高分作品 +- 使用 Agent 生成文案 +- 创建 AI 视频任务 +- 创建实拍剪辑任务 + ## 本地预览 推荐直接在目录内起一个临时静态服务: diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index f7d85a5..69c5e2c 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -18,9 +18,12 @@ const appState = { documents: [], discoveryQuery: "", selectedProjectId: "", + selectedAssistantId: "", lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()), busy: false, - message: "" + message: "", + lastAction: null, + lastGeneratedCopy: null }; function safeArray(value) { @@ -242,6 +245,151 @@ function readAuthForm() { }; } +let currentActionConfig = null; + +function ensureActionUi() { + if (document.querySelector(".action-modal-backdrop")) return; + const modal = document.createElement("div"); + modal.className = "action-modal-backdrop hidden"; + modal.innerHTML = ` +
+
+
+

快速操作

+

根据当前工作区执行动作。

+
+ +
+
+
+
+ + +
+
+ `; + document.body.appendChild(modal); +} + +function renderActionFields(fields) { + return fields.map((field) => { + const common = `data-action-field="${escapeHtml(field.name)}"`; + if (field.type === "textarea") { + return ` +
+ + +
+ `; + } + if (field.type === "select") { + return ` +
+ + +
+ `; + } + if (field.type === "checkbox") { + return ` + + `; + } + if (field.type === "file") { + return ` +
+ + +
+ `; + } + return ` +
+ + +
+ `; + }).join(""); +} + +function openActionModal(config) { + ensureActionUi(); + currentActionConfig = config; + const modal = document.querySelector(".action-modal-backdrop"); + const title = document.querySelector('[data-role="action-title"]'); + const description = document.querySelector('[data-role="action-description"]'); + const fields = document.querySelector('[data-role="action-fields"]'); + const message = document.querySelector('[data-role="action-message"]'); + const submit = document.querySelector('[data-action="submit-sheet"]'); + if (!modal || !title || !description || !fields || !message || !submit) return; + title.textContent = config.title || "快速操作"; + description.textContent = config.description || ""; + fields.innerHTML = renderActionFields(config.fields || []); + message.textContent = ""; + submit.textContent = config.submitLabel || "执行"; + submit.disabled = false; + modal.classList.remove("hidden"); +} + +function closeActionModal() { + currentActionConfig = null; + document.querySelector(".action-modal-backdrop")?.classList.add("hidden"); +} + +function readActionForm() { + const values = {}; + document.querySelectorAll("[data-action-field]").forEach((element) => { + const name = element.getAttribute("data-action-field"); + if (!name) return; + if (element instanceof HTMLInputElement && element.type === "checkbox") { + values[name] = element.checked; + return; + } + if (element instanceof HTMLInputElement && element.type === "file") { + values[name] = element.files?.[0] || null; + return; + } + values[name] = element.value; + }); + return values; +} + +async function submitActionModal() { + if (!currentActionConfig?.onSubmit) return; + const message = document.querySelector('[data-role="action-message"]'); + const submit = document.querySelector('[data-action="submit-sheet"]'); + const values = readActionForm(); + if (submit) submit.disabled = true; + if (message) message.textContent = "正在执行..."; + try { + const result = await currentActionConfig.onSubmit(values); + if (result?.keepOpen) { + if (message) message.textContent = result.message || "已完成"; + } else { + closeActionModal(); + } + } catch (error) { + if (message) message.textContent = error.message || "执行失败"; + if (submit) submit.disabled = false; + return; + } + if (submit) submit.disabled = false; +} + async function storyforgeFetch(path, options = {}) { const backendUrl = (options.backendUrl || appState.session?.backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, ""); const headers = { ...(options.headers || {}) }; @@ -314,9 +462,12 @@ async function logoutSession() { appState.contentSources = []; appState.accounts = []; appState.selectedAccountId = ""; + appState.selectedAssistantId = ""; appState.selectedWorkspace = null; appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }; appState.documents = []; + appState.lastAction = null; + appState.lastGeneratedCopy = null; renderAll(); } @@ -375,6 +526,8 @@ async function bootstrap() { appState.accounts = safeArray(accounts); appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases); appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || ""; + const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId); + appState.selectedAssistantId = selectedAssistantExists ? appState.selectedAssistantId : (dashboard.assistants?.[0]?.id || ""); const selectedAccountExists = appState.accounts.some((item) => item.id === appState.selectedAccountId); const nextAccountId = selectedAccountExists ? appState.selectedAccountId : appState.accounts[0]?.id || ""; if (nextAccountId) { @@ -401,6 +554,44 @@ function getSelectedProject() { return projects.find((item) => item.id === appState.selectedProjectId) || projects[0] || null; } +function getProjectKnowledgeBases(projectId) { + return safeArray(appState.dashboard?.knowledge_bases).filter((item) => item.project_id === projectId); +} + +function getProjectAssistants(projectId) { + return safeArray(appState.dashboard?.assistants).filter((item) => item.project_id === projectId); +} + +function getSelectedAssistant() { + const assistants = safeArray(appState.dashboard?.assistants); + return assistants.find((item) => item.id === appState.selectedAssistantId) || assistants[0] || null; +} + +function getProjectOptions() { + return safeArray(appState.dashboard?.projects).map((project) => ({ value: project.id, label: project.name })); +} + +function getAssistantOptions(projectId) { + return getProjectAssistants(projectId).map((assistant) => ({ value: assistant.id, label: assistant.name })); +} + +function getKnowledgeBaseOptions(projectId) { + return getProjectKnowledgeBases(projectId).map((kb) => ({ value: kb.id, label: kb.name })); +} + +function getModelOptions() { + return safeArray(appState.dashboard?.model_profiles).map((model) => ({ value: model.id, label: model.name })); +} + +function getCompletedJobOptions() { + return safeArray(appState.dashboard?.recent_jobs) + .filter((item) => item.status === "completed") + .map((job) => ({ + value: job.id, + label: `${job.title} · ${job.line_type || "analysis"}` + })); +} + function getProjectStats(projectId) { const dashboard = appState.dashboard || {}; const knowledgeBases = safeArray(dashboard.knowledge_bases).filter((item) => item.project_id === projectId); @@ -520,7 +711,7 @@ function renderDashboardScreen() { return screenShell( "项目总台", "先看项目状态、待办动作和高价值对标。", - `${button("新建项目", "create-project")} ${button("刷新", "refresh-data")} ${button("找对标", "goto-discovery", "primary")}`, + `${button("新建项目", "create-project")} ${button("导入主页", "open-import-homepage")} ${button("创建 Agent", "open-create-assistant", "primary")}`, `
活跃项目${escapeHtml(formatNumber(projects.length))}
项目总数${escapeHtml(formatNumber(projects.filter((item) => item.description).length))} 个有说明
@@ -553,6 +744,7 @@ function renderDashboardScreen() { `).join("")}
+ ${renderLastActionCard()}

高分对标

优先看当前已同步账号
@@ -625,7 +817,7 @@ function renderProjectsScreen() { return screenShell( "我的项目", "先建项目,再决定是否绑定自己的账号。", - `${button("新建项目", "create-project", "primary")} ${button("刷新", "refresh-data")}`, + `${button("新建项目", "create-project", "primary")} ${button("导入作品", "open-import-video-link")} ${button("导入文本", "open-import-text")} ${button("上传视频", "open-upload-video")}`, `

当前项目

@@ -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")}`, `
@@ -960,7 +1152,7 @@ function renderPlaybookScreen() { return screenShell( "Agent", "这里接真实 Agent 列表,后面再继续补创建和编辑动作。", - `${button("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`, + `${button("新建 Agent", "open-create-assistant")} ${button("生成文案", "open-generate-copy")} ${button("去生产", "goto-production", "primary")}`, `

Agent 概览

@@ -1007,6 +1199,19 @@ function renderPlaybookScreen() { `).join("") || `

还没有学习素材

先去找对标导入一条主页或作品。

`}
+
+

最近生成

当前先承接文案生成结果
+ ${appState.lastGeneratedCopy ? ` +
+

${escapeHtml(appState.lastGeneratedCopy.assistantName)}

+

${escapeHtml(appState.lastGeneratedCopy.content)}

+
+ 需求:${escapeHtml(brief(appState.lastGeneratedCopy.prompt, 24))} + ${escapeHtml(formatNumber(appState.lastGeneratedCopy.usedDocuments.length))} 条参考 +
+
+ ` : `

还没有生成结果

先点“生成文案”,这里会保留最近一次结果。

`} +
` ); @@ -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);