feat: add storyforge web v4 action workflows
This commit is contained in:
@@ -35,6 +35,20 @@
|
||||
- 单账号作品列表 `/v2/douyin/accounts/{id}/videos`
|
||||
- 最近知识库文档 `/v2/knowledge-bases/{id}/documents`
|
||||
|
||||
## 当前已接入的真实动作
|
||||
|
||||
- 新建项目
|
||||
- 导入主页并触发内容源同步
|
||||
- 导入作品链接并触发分析
|
||||
- 导入文本素材并触发分析
|
||||
- 上传本地视频并触发分析
|
||||
- 创建 Agent
|
||||
- 对当前 Douyin 对标账号重跑分析
|
||||
- 批量分析高分作品
|
||||
- 使用 Agent 生成文案
|
||||
- 创建 AI 视频任务
|
||||
- 创建实拍剪辑任务
|
||||
|
||||
## 本地预览
|
||||
|
||||
推荐直接在目录内起一个临时静态服务:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user