diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index 0920324..3a2b93c 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -45,6 +45,8 @@ - 创建 Agent - 对当前 Douyin 对标账号重跑分析 - 批量分析高分作品 +- 查找相似对标账号 +- 查看任务详情、事件和 artifacts/result - 使用 Agent 生成文案 - 创建 AI 视频任务 - 创建实拍剪辑任务 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 69c5e2c..106fecd 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -23,7 +23,9 @@ const appState = { busy: false, message: "", lastAction: null, - lastGeneratedCopy: null + lastGeneratedCopy: null, + lastSimilaritySearch: null, + lastJobDetail: null }; function safeArray(value) { @@ -274,6 +276,14 @@ function ensureActionUi() { function renderActionFields(fields) { return fields.map((field) => { const common = `data-action-field="${escapeHtml(field.name)}"`; + if (field.type === "html") { + return ` +
+ +
${field.html || ""}
+
+ `; + } if (field.type === "textarea") { return `
@@ -342,6 +352,7 @@ function openActionModal(config) { message.textContent = ""; submit.textContent = config.submitLabel || "执行"; submit.disabled = false; + submit.hidden = Boolean(config.hideSubmit); modal.classList.remove("hidden"); } @@ -468,6 +479,8 @@ async function logoutSession() { appState.documents = []; appState.lastAction = null; appState.lastGeneratedCopy = null; + appState.lastSimilaritySearch = null; + appState.lastJobDetail = null; renderAll(); } @@ -656,6 +669,15 @@ function getVideoLink(video) { return video.share_url || video.play_url || ""; } +async function loadJobDetail(jobId) { + const [job, events] = await Promise.all([ + storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}`), + storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}/events`).catch(() => []) + ]); + appState.lastJobDetail = { job, events: safeArray(events) }; + return appState.lastJobDetail; +} + function screenShell(title, subtitle, actionsHtml, bodyHtml) { return `
@@ -888,10 +910,11 @@ function renderDiscoveryScreen() { const videos = safeArray(appState.selectedVideos?.items); const topVideos = getHighScoreVideos(3); const latestVideos = getLatestVideos(2); + const similarCandidates = safeArray(appState.lastSimilaritySearch?.candidates).slice(0, 5); return screenShell( "找对标", "这里已经接入真实抖音账号列表和单账号详情。", - `${button("导入主页", "open-import-homepage")} ${button("账号分析", "analyze-selected-account")} ${button("高分分析", "analyze-top-videos")} ${button("切到生产", "goto-production", "primary")}`, + `${button("导入主页", "open-import-homepage")} ${button("账号分析", "analyze-selected-account")} ${button("高分分析", "analyze-top-videos")} ${button("查相似", "open-similar-search")} ${button("存对标", "open-benchmark-link", "primary")}`, `
@@ -1026,6 +1049,21 @@ function renderDiscoveryScreen() { `).join("") || `

暂无已保存对标

当前账号还没有保存过对标关系。

`}
+
+

最近相似候选

由 Agent 辅助生成
${escapeHtml(formatNumber(similarCandidates.length))} 个
+
+ ${similarCandidates.map((candidate) => ` +
+

${escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号")}

+

${escapeHtml(brief(candidate.rationale_text || "暂无理由", 96))}

+
+ 启发分 ${escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score || 0))} + ${candidate.candidate_profile_url ? `打开主页` : ""} +
+
+ `).join("") || `

还没有相似候选

先点“查相似”,这里会展示最近一轮结果。

`} +
+
@@ -1251,6 +1289,7 @@ function renderProductionScreen() {
${escapeHtml(job.status)} ${escapeHtml(job.line_type || "analysis")} + 看详情
`).join("") || `

还没有任务

先去找对标导入内容。

`} @@ -1304,7 +1343,7 @@ function renderReviewScreen() {

${escapeHtml(job.title)}

${escapeHtml(brief(job.style_summary || job.transcript_text || "已完成,待补复盘。", 84))}

-
已完成${escapeHtml(job.line_type || "analysis")}
+
已完成${escapeHtml(job.line_type || "analysis")}看详情
`).join("") || `

还没有完成任务

先去生产中心跑一条链路。

`} @@ -1717,6 +1756,131 @@ function openAnalyzeTopVideosAction() { }); } +function openSimilaritySearchAction() { + const account = requireSelectedAccountRow(); + openActionModal({ + title: "查相似账号", + description: "让 Agent 基于当前账号画像找更多可借鉴对象。", + submitLabel: "开始查找", + fields: [ + { name: "maxCandidates", label: "最多候选数", type: "number", value: 8, min: 3, max: 20 }, + { name: "extraRequirements", label: "额外要求", type: "textarea", rows: 4, placeholder: "例如:优先找创业成交类、口播结构强的账号" } + ], + onSubmit: async (values) => { + const created = await storyforgeFetch("/v2/douyin/similar-searches", { + method: "POST", + body: { + source_account_id: account.id, + candidate_urls: [], + seed_linked_accounts: true, + search_public_pages: true, + model_profile_id: "", + max_candidates: Number(values.maxCandidates || 8), + extra_requirements: values.extraRequirements || "" + } + }); + const searchId = created.id || created.search_id; + const detail = searchId + ? await storyforgeFetch(`/v2/douyin/similar-searches/${encodeURIComponent(searchId)}`) + : created; + appState.lastSimilaritySearch = detail; + rememberAction("相似账号已生成", `已生成 ${formatNumber(safeArray(detail.candidates).length)} 个候选账号。`, "green", detail); + await loadDouyinAccount(account.id); + renderAll(); + } + }); +} + +function openBenchmarkLinkAction() { + const account = requireSelectedAccountRow(); + const options = safeArray(appState.accounts) + .filter((item) => item.id !== account.id) + .map((item) => ({ value: item.id, label: item.nickname || item.douyin_id || item.id })); + openActionModal({ + title: "保存对标关系", + description: "把当前账号和另一个账号关联成对标关系,便于后续持续跟踪。", + submitLabel: "保存关系", + fields: [ + { name: "targetAccountId", label: "目标账号", type: "select", value: options[0]?.value || "", options }, + { name: "relationType", label: "关系类型", type: "select", value: "benchmark", options: [ + { value: "benchmark", label: "对标" }, + { value: "learn", label: "学习" }, + { value: "watch", label: "跟踪" } + ] }, + { name: "note", label: "备注", placeholder: "例如:开场结构很强,适合持续跟踪" } + ], + onSubmit: async (values) => { + if (!values.targetAccountId) throw new Error("请先选择一个目标账号"); + await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/benchmark-links`, { + method: "POST", + body: { + target_account_ids: [values.targetAccountId], + target_profile_urls: [], + relation_type: values.relationType || "benchmark", + note: values.note || "", + search_id: appState.lastSimilaritySearch?.id || "" + } + }); + rememberAction("对标关系已保存", "当前账号的对标关系已更新。", "green"); + await loadDouyinAccount(account.id); + renderAll(); + } + }); +} + +function openJobDetailAction(jobId) { + if (!jobId) return; + setBusy(true, "正在加载任务详情..."); + loadJobDetail(jobId) + .then(({ job, events }) => { + const artifacts = JSON.stringify(job.artifacts || {}, null, 2); + const result = JSON.stringify(job.result || {}, null, 2); + openActionModal({ + title: job.title || "任务详情", + description: `状态:${job.status || "-"} · 类型:${job.line_type || job.source_type || "-"}`, + hideSubmit: true, + fields: [ + { + type: "html", + label: "任务摘要", + html: ` +
+
任务 ID${escapeHtml(job.id)}
+
状态${escapeHtml(job.status || "-")}
+
链路${escapeHtml(job.line_type || "-")}
+
创建时间${escapeHtml(formatDateTime(job.created_at))}
+
+ ` + }, + { + type: "html", + label: "事件时间线", + html: ` +
+ ${safeArray(events).slice(-6).map((event) => ` +
+

${escapeHtml(event.event_type || "event")}

+

${escapeHtml(brief(event.message || JSON.stringify(event.payload || {}), 120))}

+
+ `).join("") || `

暂无事件

当前任务还没有可显示的事件。

`} +
+ ` + }, + { type: "textarea", name: "artifactsReadonly", label: "Artifacts", value: artifacts, rows: 8 }, + { type: "textarea", name: "resultReadonly", label: "Result", value: result, rows: 8 } + ] + }); + document.querySelector('[data-action-field="artifactsReadonly"]')?.setAttribute("readonly", "readonly"); + document.querySelector('[data-action-field="resultReadonly"]')?.setAttribute("readonly", "readonly"); + }) + .catch((error) => { + alert("加载任务详情失败: " + error.message); + }) + .finally(() => { + setBusy(false, ""); + }); +} + function openGenerateCopyAction() { const assistant = requireSelectedAssistant(); openActionModal({ @@ -1929,6 +2093,18 @@ document.addEventListener("click", async (event) => { openCreateRealCutAction(); return; } + if (name === "open-similar-search") { + openSimilaritySearchAction(); + return; + } + if (name === "open-benchmark-link") { + openBenchmarkLinkAction(); + return; + } + if (name === "open-job-detail") { + openJobDetailAction(action.dataset.jobId || ""); + 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 ff35ee9..46004d1 100644 --- a/web/storyforge-web-v4/assets/styles.css +++ b/web/storyforge-web-v4/assets/styles.css @@ -649,6 +649,27 @@ select { color: #b24c4c; } +.clickable-tag { + cursor: pointer; +} + +.sheet-html { + border: 1px solid var(--line); + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff 0%, #f5f9ff 100%); + padding: 14px; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.task-item.compact { + padding: 12px 14px; +} + .two-col { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);