From f492cb3f830eed7085540cbbd907fa618b8a3d59 Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 30 Mar 2026 20:07:53 +0800 Subject: [PATCH] feat: align async workbench feedback and add ci --- .github/workflows/ci.yml | 63 ++++++++ README.md | 4 + web/storyforge-web-v4/assets/app.js | 148 ++++++++++++++++-- .../tests/workbench-pages.test.mjs | 14 ++ 4 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..74077ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: StoryForge CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + baseline: + name: Baseline checks + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Python dependencies + run: python -m pip install --upgrade pip && pip install -r collector-service/requirements.txt + + - name: Run repository baseline + run: ./scripts/check_repo_baseline.sh + + backend-tests: + name: Backend tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Python dependencies + run: python -m pip install --upgrade pip && pip install -r collector-service/requirements.txt + + - name: Run backend unittest suite + run: python -m unittest tests.test_main_agent_governance tests.test_platform_contracts tests.test_production_baseline + + web-tests: + name: Web tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Run web node tests + run: node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs web/storyforge-web-v4/tests/workbench-pages.test.mjs diff --git a/README.md b/README.md index 75a6273..eaefaf2 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。 - `data/collector/`:SQLite、任务文件、下载产物 - `docs/`:审计、实施计划、联调说明、当前 MVP 状态 +## CI + +仓库里的最小 GitHub/Gitea Actions workflow 位于 [`.github/workflows/ci.yml`](/Users/kris/code/StoryForge-gitea/.github/workflows/ci.yml),会在 `push`、`pull_request` 和 `workflow_dispatch` 时运行基线检查、后端单元测试和 Web Node 测试。 + ## 产品手册 - [新媒体运营中台产品逻辑手册](./docs/PRODUCT_LOGIC_NEW_MEDIA_OPERATING_SYSTEM_2026-03-22.md) diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 278c52f..82231e8 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -59,6 +59,7 @@ const appState = { trackingCursorMap: {}, trackingAccounts: [], trackingDigest: null, + trackingRefreshNotice: null, reviews: [], liveRecorderSources: [], liveRecorderStatus: null, @@ -103,7 +104,8 @@ const appState = { mainAgentLanding: null, lastGeneratedCopy: null, lastSimilaritySearch: null, - lastJobDetail: null + lastJobDetail: null, + topVideoAnalysisResults: {} }; let PLATFORM_RUNTIME = null; @@ -2679,10 +2681,12 @@ async function refreshTrackingAccountsAction() { const payload = await storyforgeFetch(trackingRefreshPath, { method: "POST" }); + const refreshNotice = summarizeTrackingRefreshPayload(payload, platform, "batch"); + rememberTrackingRefreshNotice(refreshNotice); rememberAction( - "跟踪已同步", - `已刷新 ${formatNumber(payload.refreshed || 0)} 个账号${payload.failed ? `,失败 ${formatNumber(payload.failed)} 个` : ""}。`, - payload.failed ? "orange" : "green", + refreshNotice?.title || "跟踪已同步", + refreshNotice?.summary || `已刷新 ${formatNumber(payload.refreshed || 0)} 个账号${payload.failed ? `,失败 ${formatNumber(payload.failed)} 个` : ""}。`, + refreshNotice?.tone || (payload.failed ? "orange" : "green"), payload ); await bootstrap(); @@ -2708,13 +2712,15 @@ async function refreshTrackedAccountAction(trackedAccountId) { const payload = await storyforgeFetch(trackingRefreshPath, { method: "POST" }); + const refreshNotice = summarizeTrackingRefreshPayload(payload, platform, "single"); + rememberTrackingRefreshNotice(refreshNotice); const success = payload.success !== false; rememberAction( - success ? "单账号已同步" : "单账号刷新失败", - success + refreshNotice?.title || (success ? "单账号已同步" : "单账号刷新失败"), + refreshNotice?.summary || (success ? `已刷新「${payload.account?.nickname || trackedAccountId}」的最新作品。` - : `暂时无法刷新「${payload.account?.nickname || trackedAccountId}」:${payload.message || "请稍后重试"}`, - success ? (safeArray(payload.sync_errors).length ? "orange" : "green") : "orange", + : `暂时无法刷新「${payload.account?.nickname || trackedAccountId}」:${payload.message || "请稍后重试"}`), + refreshNotice?.tone || (success ? (safeArray(payload.sync_errors).length ? "orange" : "green") : "orange"), payload ); await bootstrap(); @@ -3371,6 +3377,82 @@ function getLatestVideos(limit = 3) { .slice(0, limit); } +function getCurrentTrackingRefreshNotice(platform = getCurrentPlatformValue()) { + const current = appState.trackingRefreshNotice || null; + if (!current) return null; + return normalizePlatformValue(current.platform || "", "") === normalizePlatformValue(platform || "", "") ? current : null; +} + +function rememberTrackingRefreshNotice(notice) { + appState.trackingRefreshNotice = notice ? { ...notice, created_at: notice.created_at || new Date().toISOString() } : null; +} + +function summarizeTrackingRefreshPayload(payload, platform, mode = "batch") { + const normalizedPlatform = normalizePlatformValue(platform || getCurrentPlatformValue(), getCurrentPlatformValue()); + if (!payload || typeof payload !== "object") { + return null; + } + if (mode === "single") { + if (payload.success === false) { + return { + platform: normalizedPlatform, + mode, + tone: "orange", + title: "单账号同步失败", + summary: `暂时无法刷新该账号:${payload.message || "请稍后重试"}`, + items: [] + }; + } + if (payload.sync_job_id) { + return { + platform: normalizedPlatform, + mode, + tone: "blue", + title: "单账号同步已排队", + summary: `已为「${payload.account?.nickname || payload.tracked_account_id || "当前账号"}」创建后台同步任务,稍后会把结果回流到日报和作品区。`, + items: [{ + tracked_account_id: payload.tracked_account_id || "", + sync_job_id: payload.sync_job_id, + status: payload.status || "queued" + }] + }; + } + return { + platform: normalizedPlatform, + mode, + tone: safeArray(payload.sync_errors).length ? "orange" : "green", + title: "单账号已刷新", + summary: `已刷新「${payload.account?.nickname || payload.tracked_account_id || "当前账号"}」的最新作品。`, + items: [] + }; + } + const queuedItems = safeArray(payload.items).filter((item) => item.sync_job_id); + if (queuedItems.length) { + return { + platform: normalizedPlatform, + mode, + tone: payload.failed ? "orange" : "blue", + title: "批量同步已排队", + summary: `已为 ${formatNumber(queuedItems.length)} 个跟踪账号创建后台同步任务${payload.failed ? `,另有 ${formatNumber(payload.failed)} 个失败` : ""}。`, + items: queuedItems + }; + } + return { + platform: normalizedPlatform, + mode, + tone: payload.failed ? "orange" : "green", + title: "批量同步已完成", + summary: `已刷新 ${formatNumber(payload.refreshed || 0)} 个账号${payload.failed ? `,失败 ${formatNumber(payload.failed)} 个` : ""}。`, + items: [] + }; +} + +function getSelectedTopVideoAnalysisResult() { + const accountId = String(getSelectedAccount()?.id || "").trim(); + if (!accountId) return null; + return appState.topVideoAnalysisResults?.[accountId] || null; +} + function getProductionWorks(limit = 6) { const preferred = safeArray(appState.selectedVideos?.items); const fallback = safeArray(appState.accounts) @@ -5477,7 +5559,7 @@ function renderDetailTabs(stateKey, tabs) { `; } -function renderDiscoveryOverviewSection({ selected, selectedProject, importedSources, tracked, workbenchReason, topVideos, reports, latestVideos, currentPlatformLabel }) { +function renderDiscoveryOverviewSection({ selected, selectedProject, importedSources, tracked, workbenchReason, topVideos, reports, latestVideos, currentPlatformLabel, topVideoBatchResult }) { return `
@@ -5523,6 +5605,29 @@ function renderDiscoveryOverviewSection({ selected, selectedProject, importedSou
+ ${topVideoBatchResult ? ` +
+
+
+

最近高分拆解

+
这批结果已经回流到当前账号页,不会只停留在顶部提醒里。
+
+ ${escapeHtml(formatNumber(topVideoBatchResult.analyzed_count || safeArray(topVideoBatchResult.items).length))} 条 +
+
+ ${safeArray(topVideoBatchResult.items).slice(0, 3).map((item) => ` +
+

${escapeHtml(item.video_title || "高分作品")}

+

${escapeHtml(item.summary_text || "已完成拆解。")}

+
+ 得分 ${escapeHtml(formatNumber(item.performance_score || 0))} + ${safeArray(item.parsed_json?.borrow_points).slice(0, 2).map((point) => `${escapeHtml(point)}`).join("")} +
+
+ `).join("")} +
+
+ ` : ""}
@@ -5622,6 +5727,7 @@ function renderDiscoveryScreen() { const selectedProject = getSelectedProject(); const importedSources = getCurrentProjectSourcesForAccount(selected, selectedProject?.id || ""); const tracked = selected?.id ? isTrackedAccount(selected.id) : false; + const topVideoBatchResult = getSelectedTopVideoAnalysisResult(); const isMobileUi = isMobileViewport(); const discoveryHandoffAttrs = buildMainAgentHandoffAttrs({ sourceScreen: "discovery", @@ -5676,7 +5782,8 @@ function renderDiscoveryScreen() { topVideos, reports, latestVideos, - currentPlatformLabel + currentPlatformLabel, + topVideoBatchResult }); } else if (activeTab === "snapshots") { detailBodyHtml = renderDouyinInsightPanel(); @@ -5851,6 +5958,7 @@ function renderTrackingScreen() { const digestItems = getTrackingDigestItems(12, { platform: currentPlatform }); const platformCursor = getTrackingCursorForPlatform(currentPlatform) || appState.lastSeenAt; const cursorLabel = platformCursor ? formatDateTime(platformCursor) : "尚未记录"; + const trackingNotice = getCurrentTrackingRefreshNotice(currentPlatform); const trackingHandoffAttrs = buildMainAgentHandoffAttrs({ sourceScreen: "tracking", sourceActionKey: "tracking-handoff", @@ -5880,6 +5988,17 @@ function renderTrackingScreen() { 上次已读 ${escapeHtml(cursorLabel)}
+ ${trackingNotice ? ` +
+

${escapeHtml(trackingNotice.title || "后台同步状态")}

+

${escapeHtml(trackingNotice.summary || "最近一次跟踪同步已经进入后台执行。")}

+
+ ${escapeHtml(trackingNotice.mode === "single" ? "单账号" : "批量同步")} + ${trackingNotice.items?.[0]?.sync_job_id ? `看任务详情` : ""} + ${actionTag("去生产中心", "goto-production")} +
+
+ ` : ""}
当前跟踪任务 @@ -9569,6 +9688,15 @@ function openAnalyzeTopVideosAction() { temperature: 0.25 } }); + appState.topVideoAnalysisResults = { + ...(appState.topVideoAnalysisResults || {}), + [account.id]: { + ...result, + account_id: account.id, + platform, + created_at: new Date().toISOString() + } + }; rememberAction("高分作品分析完成", `已补分析 ${formatNumber(result.analyzed_count)} 条高分作品。`, "green", result); await loadPlatformAccount(platform, account.id); renderAll(); diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 4f17407..c5017a4 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -285,6 +285,20 @@ test("quota and review screens foreground live next-step guidance", () => { assert.match(review, /已发布/); }); +test("tracking refresh and top-video analysis flows expose async feedback inside the workbench", () => { + const tracking = extractBetween(APP, "function renderTrackingScreen()", "function renderAutomationScreen()"); + const discovery = extractBetween(APP, "function renderDiscoveryOverviewSection(", "function renderDiscoveryRelationsSection("); + + assert.match(APP, /function summarizeTrackingRefreshPayload\(/); + assert.match(APP, /function rememberTrackingRefreshNotice\(/); + assert.match(tracking, /批量同步已排队|单账号同步已排队|后台同步状态/); + assert.match(tracking, /去生产中心/); + + assert.match(APP, /function getSelectedTopVideoAnalysisResult\(/); + assert.match(discovery, /最近高分拆解/); + assert.match(discovery, /这批结果已经回流到当前账号页/); +}); + test("discovery and production screens expose compact mobile flow summaries", () => { const discovery = extractBetween(APP, "function renderDiscoveryScreen()", "function renderTrackingScreen()"); const production = extractBetween(APP, "function renderProductionScreen()", "function renderReviewScreen()");