diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index a8a03da..0c23314 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -48,6 +48,8 @@ - 导入文本素材并触发分析 - 上传本地视频并触发分析 - 创建 Agent +- 选择当前 Agent +- 编辑 Agent 的名称、目标、系统提示词和主模型 - 对当前 Douyin 对标账号重跑分析 - 批量分析高分作品 - 查找相似对标账号 @@ -62,6 +64,7 @@ - 在生产中心 / 发布与复盘常驻最近一次任务详情摘要 - 在 Web 中直接创建和编辑复盘 - 在页面里直接看到 `本机模型 / cutvideo / huobao / n8n / ASR` 的真实健康状态 +- 会先识别后端是否具备 `tracking / reviews / integrations` 路由,再决定是否请求,避免不同版本 live collector 刷 404 - 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因 - 使用 Agent 生成文案 - 创建 AI 视频任务 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index aab5987..d3ba72f 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -25,6 +25,7 @@ const appState = { reviews: [], integrationHealth: null, localModelCatalog: null, + backendCapabilities: null, busy: false, message: "", lastAction: null, @@ -488,6 +489,21 @@ async function storyforgeFetch(path, options = {}) { return payload; } +async function loadBackendCapabilities(backendUrl) { + const normalizedUrl = (backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, ""); + const response = await fetch(`${normalizedUrl}/openapi.json`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const payload = await response.json(); + return new Set(Object.keys(payload.paths || {})); +} + +function backendSupports(path) { + if (!(appState.backendCapabilities instanceof Set)) return true; + return appState.backendCapabilities.has(path); +} + async function loginWithForm() { const auth = readAuthForm(); if (!auth.backendUrl) { @@ -540,6 +556,7 @@ async function logoutSession() { appState.trackingDigest = null; appState.reviews = []; appState.integrationHealth = null; + appState.backendCapabilities = null; appState.lastAction = null; appState.lastGeneratedCopy = null; appState.lastSimilaritySearch = null; @@ -598,25 +615,37 @@ async function bootstrap() { renderAll(); return; } + appState.backendCapabilities = await loadBackendCapabilities(appState.session.backendUrl).catch(() => null); + const supportsTracking = backendSupports("/v2/douyin/tracking/accounts"); + const supportsTrackingDigest = backendSupports("/v2/douyin/tracking/digest"); + const supportsReviews = backendSupports("/v2/reviews"); + const supportsIntegrationHealth = backendSupports("/v2/integrations/health"); + const supportsLocalModels = backendSupports("/v2/integrations/local-models"); const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog] = await Promise.all([ storyforgeFetch("/v2/me/dashboard"), storyforgeFetch("/v2/content-sources").catch(() => []), storyforgeFetch("/v2/douyin/accounts").catch(() => []), - storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })), - storyforgeFetch("/v2/reviews").catch(() => []), - storyforgeFetch("/v2/integrations/health").catch(() => null), - storyforgeFetch("/v2/integrations/local-models").catch(() => null) + supportsTracking ? storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })) : Promise.resolve({ items: [], cursor_last_seen_at: "" }), + supportsReviews ? storyforgeFetch("/v2/reviews").catch(() => []) : Promise.resolve([]), + supportsIntegrationHealth ? storyforgeFetch("/v2/integrations/health").catch(() => null) : Promise.resolve(null), + supportsLocalModels ? storyforgeFetch("/v2/integrations/local-models").catch(() => null) : Promise.resolve(null) ]); const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || ""; if (trackingCursorLastSeenAt) { setLastSeenAt(trackingCursorLastSeenAt); } const trackingSince = trackingCursorLastSeenAt || getTrackingSinceIso(); - const trackingDigest = await storyforgeFetch(`/v2/douyin/tracking/digest?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({ + const trackingDigest = supportsTrackingDigest + ? await storyforgeFetch(`/v2/douyin/tracking/digest?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({ + items: [], + tracked_accounts: [], + cursor_last_seen_at: trackingCursorLastSeenAt + })) + : ({ items: [], tracked_accounts: [], cursor_last_seen_at: trackingCursorLastSeenAt - })); + }); appState.dashboard = dashboard; appState.contentSources = safeArray(contentSources); appState.accounts = safeArray(accounts); @@ -650,6 +679,11 @@ async function bootstrap() { } async function markTrackingDigestRead() { + if (!backendSupports("/v2/douyin/tracking/cursor")) { + rememberAction("当前后端暂不支持", "这套 live collector 还没有接入跟踪已读游标。", "orange"); + renderAll(); + return; + } const nextSeenAt = new Date().toISOString(); await storyforgeFetch("/v2/douyin/tracking/cursor", { method: "POST", @@ -659,6 +693,11 @@ async function markTrackingDigestRead() { } async function refreshTrackingAccountsAction() { + if (!backendSupports("/v2/douyin/tracking/refresh")) { + rememberAction("当前后端暂不支持", "这套 live collector 还没有接入批量跟踪同步。", "orange"); + renderAll(); + return; + } setBusy(true, "正在同步跟踪账号..."); try { const payload = await storyforgeFetch("/v2/douyin/tracking/refresh", { @@ -680,6 +719,11 @@ async function refreshTrackedAccountAction(trackedAccountId) { if (!trackedAccountId) { throw new Error("trackedAccountId is required"); } + if (!backendSupports("/v2/douyin/tracking/accounts/{tracked_account_id}/refresh")) { + rememberAction("当前后端暂不支持", "这套 live collector 还没有接入单账号跟踪同步。", "orange"); + renderAll(); + return; + } setBusy(true, "正在同步该跟踪账号..."); try { const payload = await storyforgeFetch(`/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`, { @@ -1689,6 +1733,14 @@ function renderTrackingScreen() { if (!appState.dashboard) { return screenShell("跟踪账号", "登录后才能生成真实日报。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("日报未加载", "当前还没有可用的对标账号数据。")); } + if (!backendSupports("/v2/douyin/tracking/accounts")) { + return screenShell( + "跟踪账号", + "当前 live collector 还没有接入跟踪日报接口。", + `${button("跳到找对标", "goto-discovery", "primary")}`, + renderEmptyState("跟踪能力暂未接入", "这套后端还缺 /v2/douyin/tracking/*,等 live collector 同步后这里会自动切成真实日报。") + ); + } const trackedAccounts = safeArray(appState.trackingAccounts); const digestItems = getTrackingDigestItems(12); const cursorLabel = appState.lastSeenAt ? formatDateTime(appState.lastSeenAt) : "尚未记录"; @@ -1845,11 +1897,12 @@ function renderPlaybookScreen() { const assistants = safeArray(appState.dashboard.assistants); const models = safeArray(appState.dashboard.model_profiles); const currentModel = getCurrentModelProfile(); + const currentAssistant = getSelectedAssistant(); const localCatalog = appState.localModelCatalog || {}; const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean); return screenShell( "Agent", - "这里接真实 Agent 列表,后面再继续补创建和编辑动作。", + "这里接真实 Agent 列表,当前已经支持切换和编辑 Agent。", `${button("设主模型", "open-preferred-model")} ${button("新建 Agent", "open-create-assistant")} ${button("生成文案", "open-generate-copy")} ${button("去生产", "goto-production", "primary")}`, `
@@ -1859,6 +1912,29 @@ function renderPlaybookScreen() { ${models.slice(0, 6).map((model) => `${escapeHtml(model.name)}`).join("") || `暂无模型`}
+
+
+
+

当前 Agent

+
后续文案生成、对标绑定和复盘默认都会优先使用这里选中的 Agent
+
+
+ ${currentAssistant ? `已选` : `未选`} + ${currentAssistant ? `编辑` : ""} +
+
+ ${currentAssistant ? ` +
+

${escapeHtml(currentAssistant.name)}

+

${escapeHtml(currentAssistant.generation_goal || currentAssistant.description || "先补齐这个 Agent 的目标和说明。")}

+
+ ${escapeHtml(models.find((item) => item.id === currentAssistant.model_profile_id)?.name || "默认模型")} + ${escapeHtml(formatNumber(safeArray(currentAssistant.knowledge_base_ids).length))} 条知识库 + ${escapeHtml(brief(currentAssistant.description || "暂无说明", 22))} +
+
+ ` : `

还没有可用 Agent

先创建一个 Agent,再把当前项目的内容都交给它学习。

`} +
@@ -1894,12 +1970,14 @@ function renderPlaybookScreen() {

Agent 列表

当前接的是后端 assistants
${assistants.map((assistant) => ` -
+

${escapeHtml(assistant.name)}

${escapeHtml(assistant.description || assistant.generation_goal || "暂无说明")}

知识库 ${escapeHtml(formatNumber(safeArray(assistant.knowledge_base_ids).length))} ${escapeHtml(models.find((item) => item.id === assistant.model_profile_id)?.name || "默认模型")} + ${assistant.id === currentAssistant?.id ? "当前 Agent" : "设为当前"} + 编辑
`).join("") || `

还没有 Agent

下一步可以直接把创建动作接进来。

`} @@ -2017,6 +2095,14 @@ function renderReviewScreen() { if (!appState.dashboard) { return screenShell("发布与复盘", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("复盘未加载", "登录后这里会先用最近任务生成一版复盘入口。")); } + if (!backendSupports("/v2/reviews")) { + return screenShell( + "发布与复盘", + "当前 live collector 还没有接入复盘读写接口。", + `${button("去生产", "goto-production", "primary")}`, + renderEmptyState("复盘能力暂未接入", "这套后端还缺 /v2/reviews,当前可以继续跑生产任务,等 live collector 同步后这里会自动切成真实复盘工作台。") + ); + } const project = getSelectedProject(); const completed = safeArray(appState.dashboard.recent_jobs).filter((item) => item.status === "completed").slice(0, 4); const reviews = getProjectReviews(project?.id || "").slice(0, 8); @@ -2595,6 +2681,43 @@ function openCreateAssistantAction() { }); } +function openEditAssistantAction(assistantId = "") { + const assistant = safeArray(appState.dashboard?.assistants).find((item) => item.id === assistantId) || getSelectedAssistant(); + if (!assistant) { + alert("请先选择一个 Agent"); + return; + } + const modelOptions = getModelOptions(); + openActionModal({ + title: "编辑 Agent", + description: "更新当前 Agent 的名称、目标和主模型,不会影响已完成任务。", + submitLabel: "保存 Agent", + fields: [ + { name: "name", label: "名称", value: assistant.name || "", placeholder: "例如:创业成交助手" }, + { name: "description", label: "说明", value: assistant.description || "", placeholder: "例如:服务创业 IP 与成交型短视频" }, + { name: "goal", label: "生成目标", value: assistant.generation_goal || "", placeholder: "例如:输出创业口播、对标拆解和成交文案" }, + { name: "systemPrompt", label: "系统提示词", type: "textarea", rows: 5, value: assistant.system_prompt || "", placeholder: "可选,不填则后续再补" }, + { name: "modelProfileId", label: "主模型", type: "select", value: assistant.model_profile_id || modelOptions[0]?.value || "", options: modelOptions } + ], + onSubmit: async (values) => { + if (!values.name?.trim()) throw new Error("请填写 Agent 名称"); + const updated = await storyforgeFetch(`/v2/assistants/${encodeURIComponent(assistant.id)}`, { + method: "PATCH", + body: { + name: values.name.trim(), + description: values.description || "", + generation_goal: values.goal || "", + system_prompt: values.systemPrompt || "", + model_profile_id: values.modelProfileId || "" + } + }); + appState.selectedAssistantId = updated.id; + rememberAction("Agent 已更新", `已更新 Agent「${updated.name}」。`, "green", updated); + await bootstrap(); + } + }); +} + function openAnalyzeSelectedAccountAction() { const account = requireSelectedAccountRow(); openActionModal({ @@ -3149,6 +3272,16 @@ document.addEventListener("click", async (event) => { openCreateAssistantAction(); return; } + if (name === "select-assistant") { + appState.selectedAssistantId = action.dataset.assistantId || ""; + rememberAction("已切换当前 Agent", `当前默认 Agent 已更新为「${getSelectedAssistant()?.name || "未选择"}」。`, "green"); + renderAll(); + return; + } + if (name === "open-edit-assistant") { + openEditAssistantAction(action.dataset.assistantId || ""); + return; + } if (name === "analyze-selected-account") { openAnalyzeSelectedAccountAction(); return; diff --git a/web/storyforge-web-v4/assets/styles.css b/web/storyforge-web-v4/assets/styles.css index f644f6d..66a8e5e 100644 --- a/web/storyforge-web-v4/assets/styles.css +++ b/web/storyforge-web-v4/assets/styles.css @@ -629,6 +629,12 @@ select { padding: 15px; } +.task-item.active { + border-color: rgba(79, 143, 238, 0.24); + background: linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%); + box-shadow: inset 0 0 0 1px rgba(79, 143, 238, 0.08); +} + .task-item h4, .entity-card h4, .topic-card h4,