feat: add storyforge web v4 action workflows

This commit is contained in:
kris
2026-03-22 11:22:10 +08:00
parent 540be80719
commit b75c9e275b
3 changed files with 741 additions and 8 deletions

View File

@@ -35,6 +35,20 @@
- 单账号作品列表 `/v2/douyin/accounts/{id}/videos`
- 最近知识库文档 `/v2/knowledge-bases/{id}/documents`
## 当前已接入的真实动作
- 新建项目
- 导入主页并触发内容源同步
- 导入作品链接并触发分析
- 导入文本素材并触发分析
- 上传本地视频并触发分析
- 创建 Agent
- 对当前 Douyin 对标账号重跑分析
- 批量分析高分作品
- 使用 Agent 生成文案
- 创建 AI 视频任务
- 创建实拍剪辑任务
## 本地预览
推荐直接在目录内起一个临时静态服务:

View File

@@ -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 = `
<div class="action-modal">
<div class="auth-head">
<div>
<h3 data-role="action-title">快速操作</h3>
<p data-role="action-description">根据当前工作区执行动作。</p>
</div>
<button class="btn btn-secondary" type="button" data-action="close-sheet">关闭</button>
</div>
<div class="field-stack" data-role="action-fields"></div>
<div class="helper-text" data-role="action-message"></div>
<div class="auth-actions">
<button class="btn btn-secondary" type="button" data-action="close-sheet">取消</button>
<button class="btn btn-primary" type="button" data-action="submit-sheet">执行</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
function renderActionFields(fields) {
return fields.map((field) => {
const common = `data-action-field="${escapeHtml(field.name)}"`;
if (field.type === "textarea") {
return `
<div class="field-stack">
<label>${escapeHtml(field.label)}</label>
<textarea ${common} rows="${escapeHtml(field.rows || 4)}" placeholder="${escapeHtml(field.placeholder || "")}">${escapeHtml(field.value || "")}</textarea>
</div>
`;
}
if (field.type === "select") {
return `
<div class="field-stack">
<label>${escapeHtml(field.label)}</label>
<select ${common}>
${(field.options || []).map((option) => `
<option value="${escapeHtml(option.value)}" ${String(option.value) === String(field.value ?? "") ? "selected" : ""}>${escapeHtml(option.label)}</option>
`).join("")}
</select>
</div>
`;
}
if (field.type === "checkbox") {
return `
<label class="checkbox-row">
<input type="checkbox" ${common} ${field.value ? "checked" : ""} />
<span>${escapeHtml(field.label)}</span>
</label>
`;
}
if (field.type === "file") {
return `
<div class="field-stack">
<label>${escapeHtml(field.label)}</label>
<input type="file" ${common} accept="${escapeHtml(field.accept || "")}" />
</div>
`;
}
return `
<div class="field-stack">
<label>${escapeHtml(field.label)}</label>
<input
type="${escapeHtml(field.type || "text")}"
${common}
value="${escapeHtml(field.value || "")}"
placeholder="${escapeHtml(field.placeholder || "")}"
${field.min != null ? `min="${escapeHtml(field.min)}"` : ""}
${field.max != null ? `max="${escapeHtml(field.max)}"` : ""}
/>
</div>
`;
}).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")}`,
`
<div class="layout-grid grid-5">
<div class="stat-card"><small>活跃项目</small><strong>${escapeHtml(formatNumber(projects.length))}</strong><div class="stat-foot"><span>项目总数</span><span class="positive">${escapeHtml(formatNumber(projects.filter((item) => item.description).length))} 个有说明</span></div></div>
@@ -553,6 +744,7 @@ function renderDashboardScreen() {
`).join("")}
</div>
</div>
${renderLastActionCard()}
<div class="panel pad">
<div class="panel-head"><div><h3>高分对标</h3><div class="panel-subtitle">优先看当前已同步账号</div></div></div>
<div class="three-col">
@@ -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")}`,
`
<div class="hero-card">
<h3>当前项目</h3>
@@ -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")}`,
`
<div class="panel">
<div class="toolbar">
@@ -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")}`,
`
<div class="hero-card">
<h3>Agent 概览</h3>
@@ -1007,6 +1199,19 @@ function renderPlaybookScreen() {
`).join("") || `<div class="task-item"><h4>还没有学习素材</h4><p>先去找对标导入一条主页或作品。</p></div>`}
</div>
</div>
<div class="panel pad">
<div class="panel-head"><div><h3>最近生成</h3><div class="panel-subtitle"></div></div></div>
${appState.lastGeneratedCopy ? `
<div class="task-item">
<h4>${escapeHtml(appState.lastGeneratedCopy.assistantName)}</h4>
<p>${escapeHtml(appState.lastGeneratedCopy.content)}</p>
<div class="task-meta">
<span class="tag blue">需求:${escapeHtml(brief(appState.lastGeneratedCopy.prompt, 24))}</span>
<span class="tag">${escapeHtml(formatNumber(appState.lastGeneratedCopy.usedDocuments.length))} 条参考</span>
</div>
</div>
` : `<div class="task-item"><h4>还没有生成结果</h4><p>先点“生成文案”,这里会保留最近一次结果。</p></div>`}
</div>
</div>
`
);
@@ -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")}`,
`
<div class="panel pad">
<div class="panel-head"><div><h3>生产队列</h3><div class="panel-subtitle"></div></div></div>
@@ -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 `
<div class="panel pad">
<div class="panel-head">
<div>
<h3>最近动作</h3>
<div class="panel-subtitle">${escapeHtml(formatDateTime(appState.lastAction.createdAt))}</div>
</div>
<span class="tag ${escapeHtml(appState.lastAction.tone || "blue")}">${escapeHtml(appState.lastAction.title)}</span>
</div>
<div class="task-item">
<h4>${escapeHtml(appState.lastAction.title)}</h4>
<p>${escapeHtml(appState.lastAction.summary)}</p>
</div>
</div>
`;
}
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;

View File

@@ -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);