diff --git a/collector-service/app/douyin_features.py b/collector-service/app/douyin_features.py index 393cf81..5ca66b6 100644 --- a/collector-service/app/douyin_features.py +++ b/collector-service/app/douyin_features.py @@ -1885,6 +1885,33 @@ def register_douyin_routes(app: Any, legacy: Any) -> None: "items": items[: max(1, min(limit, 100))] } + async def _refresh_tracked_account_workspace( + owner: dict[str, Any], + tracked_account_id: str, + discovery_note: str = "tracking_refresh" + ) -> dict[str, Any]: + account_row = _require_owned_account(tracked_account_id, owner["id"]) + profile_url = _first_non_empty( + account_row.get("canonical_profile_url"), + account_row.get("profile_url") + ) + if not profile_url: + raise HTTPException(status_code=400, detail="Tracked account has no profile_url to refresh") + request = DouyinAccountSyncRequest( + profile_url=profile_url, + compact_response=True, + discovery_note=discovery_note + ) + public_data = await _collect_public_profile(profile_url, None) + creator_data = {"pages": [], "errors": []} + return await run_in_threadpool( + _finalize_sync_workspace, + owner, + request, + public_data, + creator_data + ) + def _normalize_report_text(value: Any) -> str: text = str(value or "").strip() if not text: @@ -3374,6 +3401,71 @@ def register_douyin_routes(app: Any, legacy: Any) -> None: "items": _list_tracked_accounts(account["id"]) } + @app.post("/v2/douyin/tracking/accounts/{tracked_account_id}/refresh") + async def refresh_douyin_tracked_account( + tracked_account_id: str, + account: dict[str, Any] = Depends(legacy.require_approved) + ) -> dict[str, Any]: + account_row = _require_owned_account(tracked_account_id, account["id"]) + account_payload = _build_account_payload(account_row, include_recent_videos=6) + try: + refreshed = await _refresh_tracked_account_workspace(account, tracked_account_id) + return { + "success": True, + "tracked_account_id": tracked_account_id, + "account": refreshed.get("account", {}), + "sync_errors": refreshed.get("sync_errors", []), + "public_video_count": refreshed.get("public_video_count", 0), + "creator_page_count": refreshed.get("creator_page_count", 0) + } + except HTTPException as exc: + detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)} + return { + "success": False, + "tracked_account_id": tracked_account_id, + "account": account_payload, + "message": detail.get("message") or str(exc.detail), + "detail": detail, + "sync_errors": detail.get("public_errors", []) + detail.get("creator_errors", []) + } + + @app.post("/v2/douyin/tracking/refresh") + async def refresh_all_douyin_tracked_accounts( + account: dict[str, Any] = Depends(legacy.require_approved) + ) -> dict[str, Any]: + tracked_accounts = _list_tracked_accounts(account["id"]) + items: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + for tracked in tracked_accounts: + try: + refreshed = await _refresh_tracked_account_workspace(account, tracked["tracked_account_id"]) + items.append({ + "tracking_id": tracked["id"], + "tracked_account_id": tracked["tracked_account_id"], + "nickname": (refreshed.get("account") or {}).get("nickname", ""), + "sync_errors": refreshed.get("sync_errors", []), + "public_video_count": refreshed.get("public_video_count", 0) + }) + except HTTPException as exc: + errors.append({ + "tracking_id": tracked["id"], + "tracked_account_id": tracked["tracked_account_id"], + "message": str(exc.detail) + }) + except Exception as exc: + errors.append({ + "tracking_id": tracked["id"], + "tracked_account_id": tracked["tracked_account_id"], + "message": str(exc) + }) + return { + "tracked_count": len(tracked_accounts), + "refreshed": len(items), + "failed": len(errors), + "items": items, + "errors": errors + } + @app.post("/v2/douyin/tracking/cursor") def update_douyin_tracking_cursor( request: DouyinTrackingCursorRequest, diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index db456df..57d40e3 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -35,6 +35,8 @@ - 单账号作品列表 `/v2/douyin/accounts/{id}/videos` - 跟踪账号 `/v2/douyin/tracking/accounts` - 跟踪日报 `/v2/douyin/tracking/digest` +- 发布复盘 `/v2/reviews` +- 集成健康 `/v2/integrations/health` - 最近知识库文档 `/v2/knowledge-bases/{id}/documents` ## 当前已接入的真实动作 @@ -51,10 +53,16 @@ - 查找相似对标账号 - 从相似候选一键保存对标关系 - 把当前对标账号加入跟踪,并绑定 Agent +- 单账号立即同步跟踪对象 +- 批量同步全部跟踪对象 +- 日报手动标记已读,不再在刷新页面时自动吞掉未读摘要 - 按上次打开后生成跟踪日报与借鉴点摘要 - 查看任务详情、事件、子任务和 artifacts/result - 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成 - 在生产中心 / 发布与复盘常驻最近一次任务详情摘要 +- 在 Web 中直接创建和编辑复盘 +- 在页面里直接看到 `cutvideo / huobao / n8n / ASR` 的真实健康状态 +- 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因 - 使用 Agent 生成文案 - 创建 AI 视频任务 - 创建实拍剪辑任务 @@ -78,10 +86,10 @@ python3 -m http.server 3918 ## 后续建议 -- 继续补动作型接口,例如导入、绑定 Agent、触发分析与生产 +- 继续补多平台真实接入,而不只是一套 Douyin 工作流 - 把对标导入后的 Agent 绑定和知识库入库反馈做得更完整 -- 把跟踪日报从 Douyin 扩到多平台统一模型 +- 把跟踪日报从 Douyin 扩到多平台统一模型,并接入真正的定时调度 - 把全局搜索和页内搜索合并成统一搜索体验 -- 为 `生产中心 / 发布与复盘` 接入更完整的任务与成片对象 +- 为 `生产中心 / 发布与复盘` 接入更完整的成片预览与封面对象 - 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs` - 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index fdb57a0..a2ef520 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -32,6 +32,44 @@ const appState = { lastJobDetail: null }; +const INTEGRATION_ORDER = ["cutvideo", "huobao", "n8n", "asr"]; +const INTEGRATION_META = { + cutvideo: { + label: "自动剪辑", + hint: "Windows cutvideo", + impacts: ["实拍剪辑"] + }, + huobao: { + label: "AI 视频", + hint: "huobao-drama", + impacts: ["AI 视频"] + }, + n8n: { + label: "编排", + hint: "n8n workflow", + impacts: ["AI 视频", "实拍剪辑", "自动链路"] + }, + asr: { + label: "ASR", + hint: "素材转写", + impacts: ["分析转写"] + } +}; +const PIPELINE_GUARDS = { + aiVideo: { + label: "AI 视频", + openAction: "open-ai-video", + jobAction: "job-to-ai-video", + dependencies: ["n8n", "huobao"] + }, + realCut: { + label: "实拍剪辑", + openAction: "open-real-cut", + jobAction: "job-to-real-cut", + dependencies: ["n8n", "cutvideo"] + } +}; + function safeArray(value) { return Array.isArray(value) ? value : []; } @@ -592,12 +630,6 @@ async function bootstrap() { appState.selectedWorkspace = null; appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }; } - const nextSeenAt = new Date().toISOString(); - storyforgeFetch("/v2/douyin/tracking/cursor", { - method: "POST", - body: { last_seen_at: nextSeenAt } - }).catch(() => null); - setLastSeenAt(nextSeenAt); } catch (error) { appState.message = error.message; if (String(error.message || "").includes("401") || String(error.message || "").includes("Not authenticated")) { @@ -609,6 +641,57 @@ async function bootstrap() { } } +async function markTrackingDigestRead() { + const nextSeenAt = new Date().toISOString(); + await storyforgeFetch("/v2/douyin/tracking/cursor", { + method: "POST", + body: { last_seen_at: nextSeenAt } + }); + setLastSeenAt(nextSeenAt); +} + +async function refreshTrackingAccountsAction() { + setBusy(true, "正在同步跟踪账号..."); + try { + const payload = await storyforgeFetch("/v2/douyin/tracking/refresh", { + method: "POST" + }); + rememberAction( + "跟踪已同步", + `已刷新 ${formatNumber(payload.refreshed || 0)} 个账号${payload.failed ? `,失败 ${formatNumber(payload.failed)} 个` : ""}。`, + payload.failed ? "orange" : "green", + payload + ); + await bootstrap(); + } finally { + setBusy(false, ""); + } +} + +async function refreshTrackedAccountAction(trackedAccountId) { + if (!trackedAccountId) { + throw new Error("trackedAccountId is required"); + } + setBusy(true, "正在同步该跟踪账号..."); + try { + const payload = await storyforgeFetch(`/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`, { + method: "POST" + }); + const success = payload.success !== false; + rememberAction( + success ? "单账号已同步" : "单账号刷新失败", + success + ? `已刷新「${payload.account?.nickname || trackedAccountId}」的最新作品。` + : `暂时无法刷新「${payload.account?.nickname || trackedAccountId}」:${payload.message || "请稍后重试"}`, + success ? (safeArray(payload.sync_errors).length ? "orange" : "green") : "orange", + payload + ); + await bootstrap(); + } finally { + setBusy(false, ""); + } +} + function getSelectedProject() { const projects = safeArray(appState.dashboard?.projects); return projects.find((item) => item.id === appState.selectedProjectId) || projects[0] || null; @@ -778,6 +861,126 @@ function canDeriveRealCut(job) { return ["video_link", "upload_video"].includes(sourceType); } +function hasIntegrationHealthData() { + return Boolean(appState.integrationHealth && typeof appState.integrationHealth === "object"); +} + +function getIntegrationDetail(key) { + const raw = hasIntegrationHealthData() ? appState.integrationHealth?.[key] : null; + return { + key, + available: Boolean(raw && typeof raw === "object"), + configured: Boolean(raw?.configured), + reachable: Boolean(raw?.reachable), + statusCode: Number(raw?.status_code || 0), + error: String(raw?.error || ""), + url: String(raw?.url || raw?.base_url || ""), + baseUrl: String(raw?.base_url || "") + }; +} + +function getIntegrationStatus(detail) { + if (!detail.available) { + return { tone: "blue", summary: "未拉取" }; + } + if (detail.reachable) { + return { tone: "green", summary: "在线" }; + } + if (detail.configured) { + return { tone: "red", summary: "不可达" }; + } + return { tone: "orange", summary: "未配置" }; +} + +function describeIntegrationFailure(key) { + const detail = getIntegrationDetail(key); + const meta = INTEGRATION_META[key] || { label: key }; + if (!detail.available) return `${meta.label}健康状态未拉取`; + if (!detail.configured) return `${meta.label}未配置`; + if (detail.statusCode) return `${meta.label}返回 HTTP ${detail.statusCode}`; + if (detail.error) return `${meta.label}${brief(detail.error, 42)}`; + return `${meta.label}不可达`; +} + +function getPipelineGuard(kind) { + const config = PIPELINE_GUARDS[kind]; + if (!config) { + return { enabled: true, reason: "", blocked: [] }; + } + const blocked = config.dependencies + .map((key) => ({ key, detail: getIntegrationDetail(key), meta: INTEGRATION_META[key] || { label: key } })) + .filter((item) => item.detail.available && !item.detail.reachable); + if (!blocked.length) { + return { enabled: true, reason: "", blocked: [] }; + } + return { + enabled: false, + blocked, + reason: `${config.label}暂不可用:${blocked.map((item) => describeIntegrationFailure(item.key)).join(";")}` + }; +} + +function getIntegrationCards() { + return INTEGRATION_ORDER.map((key) => { + const detail = getIntegrationDetail(key); + const status = getIntegrationStatus(detail); + const meta = INTEGRATION_META[key] || { label: key, hint: key, impacts: [] }; + let note = "尚未获取健康检查数据"; + if (detail.available) { + if (detail.reachable) { + note = detail.statusCode + ? `健康探测返回 HTTP ${detail.statusCode}` + : "TCP 探测已通过"; + } else if (!detail.configured) { + note = "后端还没有配置该依赖地址"; + } else if (detail.statusCode) { + note = `探测返回 HTTP ${detail.statusCode}`; + } else if (detail.error) { + note = brief(detail.error, 72); + } else { + note = "探测失败,请检查服务进程和网络"; + } + } + return { + key, + meta, + detail, + status, + note + }; + }); +} + +function getIntegrationOverview() { + const cards = getIntegrationCards(); + const reachableCount = cards.filter((item) => item.detail.available && item.detail.reachable).length; + const availableCount = cards.filter((item) => item.detail.available).length; + const aiVideoGuard = getPipelineGuard("aiVideo"); + const realCutGuard = getPipelineGuard("realCut"); + const blockedActions = [ + !aiVideoGuard.enabled ? aiVideoGuard.reason : "", + !realCutGuard.enabled ? realCutGuard.reason : "" + ].filter(Boolean); + const tone = !availableCount + ? "blue" + : blockedActions.length + ? "red" + : cards.some((item) => item.detail.available && !item.detail.reachable) + ? "orange" + : "green"; + const headline = !availableCount + ? "依赖健康尚未拉取" + : blockedActions.length + ? `自动链路受阻:${blockedActions.length} 项` + : `${reachableCount}/${cards.length} 项依赖在线`; + const subtitle = !availableCount + ? "刷新后会显示 cutvideo / huobao / n8n / ASR 的真实状态。" + : blockedActions.length + ? blockedActions.join(";") + : "AI 视频与实拍剪辑链路当前可直接发起。"; + return { cards, tone, headline, subtitle }; +} + function getJobSeedBrief(job) { return [ job?.style_summary, @@ -885,8 +1088,103 @@ function screenShell(title, subtitle, actionsHtml, bodyHtml) { `; } -function button(label, action, tone = "secondary") { - return ``; +function button(label, action, tone = "secondary", options = {}) { + const classes = ["btn", `btn-${tone}`]; + if (options.className) classes.push(options.className); + if (options.disabledReason) classes.push("is-disabled"); + const targetAction = options.disabledReason ? "show-disabled-reason" : action; + const title = options.disabledReason || options.title || ""; + return ` + + `.replace(/\s+/g, " ").trim(); +} + +function actionTag(label, action, attrs = "", options = {}) { + const classes = ["tag"]; + const targetAction = options.disabledReason ? "show-disabled-reason" : action; + if (options.disabledReason) { + classes.push("tag-disabled"); + } else if (targetAction) { + classes.push("clickable-tag"); + } + const title = options.disabledReason || options.title || ""; + return ` + ${escapeHtml(label)} + `.replace(/\s+/g, " ").trim(); +} + +function renderPipelineButton(kind, tone = "secondary") { + const config = PIPELINE_GUARDS[kind]; + if (!config) return ""; + const guard = getPipelineGuard(kind); + return button(config.label, config.openAction, tone, { + disabledReason: guard.enabled ? "" : guard.reason + }); +} + +function renderPipelineJobTag(kind, job, label) { + const config = PIPELINE_GUARDS[kind]; + if (!config || !job?.id) return ""; + const guard = getPipelineGuard(kind); + return actionTag(label, config.jobAction, `data-job-id="${escapeHtml(job.id)}"`, { + disabledReason: guard.enabled ? "" : guard.reason + }); +} + +function renderIntegrationOverviewPanel(options = {}) { + const overview = getIntegrationOverview(); + const cards = overview.cards; + const showActions = options.showActions !== false; + return ` +
+ `; } function renderEmptyState(title, description) { @@ -936,6 +1234,9 @@ function renderDashboardScreen() {先去找对标把重点账号加入跟踪。
当前按真实任务量给出一版轻量看板,后续再接更完整的定时与重试配置。
+当前按真实任务量和依赖健康状态给出看板,自动流程受阻时会直接在这里拦住动作。
${escapeHtml(item.hint)}
- -先去找对标导入内容。
先去生产中心跑一条链路。
${escapeHtml(brief(detail.job.style_summary || detail.job.transcript_text || detail.job.error || "暂无摘要", 120))}