diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb4f9d..9f22040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## 2026-04-07 +### AI 视频表单可直接跳到火山视频配置状态 + +- `创建 AI 视频任务` 里的 `Seedance 配置` 提示现在不再只是静态文案,而是新增了 `查看火山配置状态` 入口。 +- 点击后会直接跳到 `自动流程 -> 依赖健康 -> Huobao` 卡片,立刻看到当前火山视频配置是否就绪、部署位置和配置提示,不用再自己记 `/settings/ai-config -> 视频 -> 火山引擎` 再手动找入口。 +- 同时 `依赖健康` 里的各张集成卡现在都带稳定锚点,后续其他配置提示也可以直接把用户带到最相关的健康卡,而不是只停在说明文字里。 + ### 修复额度页套餐建议引起的全局渲染报错 - `额度` 页面现在会先初始化 `packageRecommendation` 再渲染套餐建议,不再因为变量未定义把整个工作台渲染链打断。 @@ -662,3 +668,9 @@ - `创建 Agent / 编辑 Agent` 这两张表单也补成了带上下文和知识库联动的产品化表单:创建时切项目会同步刷新默认知识库,编辑时可以直接更新默认知识库,不必再回别处改。 - 额度页残留的半成品口径已收口,不再出现“后端尚未完全接入真实预算”这类提示;未配置独立额度策略时,会直接引导按预算基线和动作池去建立试用、增长或规模套餐。 - `smoke_public_storyforge.sh` 和 `smoke_fnos_storyforge_lan.sh` 现在会显式校验 `integrations/health` 的关键依赖状态、部署位置和 `local_model=not_configured` 口径,不再只看页面能打开和基础 healthz。 + +# 2026-04-07 + +- 顶层 `AI 视频 / 实拍剪辑` 主按钮改回“先开配置表单”,会自动承接最近完成任务作为默认来源,但不再直接跳过配置页;只有任务上下文里的 `做 AI 视频 / 做实拍剪辑` 仍保持 direct-execute。 +- `AI 视频` 表单新增 `Seedance 配置` 提示,明确说明当前 `Seedance 2.0` 走火山视频配置,默认应在 `Huobao /settings/ai-config -> 视频 -> 火山引擎` 配置;如果不用页面配置,也支持通过 `HUOBAO_VIDEO_BASE_URL / HUOBAO_VIDEO_API_KEY / HUOBAO_VIDEO_MODELS` 环境变量覆盖。 +- `integrations/health` 新增 `huobao` 视频配置摘要,能直接看出当前 `Huobao` 视频配置页是否已经录入视频引擎配置,以及对应的配置页路径,减少排查 `Seedance` 任务为什么只建单不出片的歧义。 diff --git a/collector-service/app/core_main.py b/collector-service/app/core_main.py index 3e6af45..e3c4d77 100644 --- a/collector-service/app/core_main.py +++ b/collector-service/app/core_main.py @@ -3188,6 +3188,39 @@ def probe_http_json(url: str, path: str = "", timeout: float = 3.0) -> dict[str, return detail +def probe_huobao_video_config(url: str, timeout: float = 5.0) -> dict[str, Any]: + detail = { + "video_config_route": "/settings/ai-config -> 视频 -> 火山引擎", + "video_config_count": 0, + "video_config_ready": False, + } + if not url: + return detail + target_url = urljoin(url if url.endswith("/") else f"{url}/", "api/v1/ai-configs") + try: + response = httpx.get( + target_url, + params={"service_type": "video"}, + timeout=timeout, + follow_redirects=True, + ) + if response.status_code >= 500: + return detail + payload = response.json() + items: list[dict[str, Any]] = [] + if isinstance(payload, list): + items = [item for item in payload if isinstance(item, dict)] + elif isinstance(payload, dict): + raw_items = payload.get("data") if isinstance(payload.get("data"), list) else payload.get("configs") + if isinstance(raw_items, list): + items = [item for item in raw_items if isinstance(item, dict)] + detail["video_config_count"] = len(items) + detail["video_config_ready"] = bool(items) + except Exception: + pass + return detail + + def live_recorder_request(method: str, path: str, payload: dict[str, Any] | None = None, timeout: float = 20.0) -> Any: if not LIVE_RECORDER_BASE_URL: raise HTTPException(status_code=503, detail="LIVE_RECORDER_BASE_URL is not configured") @@ -3312,6 +3345,8 @@ def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> cutvideo_uploads = probe_http(CUTVIDEO_BASE_URL, "/api/uploads", timeout=5.0) asr_probe = probe_http_json(ASR_HTTP_BASE_URL, "/health", timeout=5.0) asr_runtime = asr_probe.get("json") if isinstance(asr_probe.get("json"), dict) else {} + huobao_probe = probe_http(HUOBAO_BASE_URL, "/health", timeout=5.0) + huobao_video_config = probe_huobao_video_config(HUOBAO_BASE_URL, timeout=5.0) cutvideo_supports_uploads = bool( cutvideo_uploads.get("configured") and cutvideo_uploads.get("reachable") @@ -3336,7 +3371,8 @@ def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> "huobao": { "base_url": HUOBAO_BASE_URL, **integration_deployment_payload("huobao", HUOBAO_BASE_URL), - **probe_http(HUOBAO_BASE_URL, "/health"), + **huobao_probe, + **huobao_video_config, }, "n8n": { "base_url": N8N_BASE_URL, diff --git a/tests/test_production_baseline.py b/tests/test_production_baseline.py index ceb0bff..ba8df21 100644 --- a/tests/test_production_baseline.py +++ b/tests/test_production_baseline.py @@ -352,6 +352,7 @@ class ProductionBaselineTests(unittest.TestCase): original_cutvideo = self.core.CUTVIDEO_BASE_URL original_probe_http = self.core.probe_http original_probe_http_json = getattr(self.core, "probe_http_json", None) + original_probe_huobao_video_config = getattr(self.core, "probe_huobao_video_config", None) try: self.core.N8N_BASE_URL = "http://127.0.0.1:25670" self.core.HUOBAO_BASE_URL = "http://127.0.0.1:25678" @@ -381,8 +382,16 @@ class ProductionBaselineTests(unittest.TestCase): } return detail + def fake_probe_huobao_video_config(url: str, timeout: float = 5.0) -> dict[str, Any]: + return { + "video_config_route": "/settings/ai-config -> 视频 -> 火山引擎", + "video_config_count": 1, + "video_config_ready": True, + } + self.core.probe_http = fake_probe_http self.core.probe_http_json = fake_probe_http_json + self.core.probe_huobao_video_config = fake_probe_huobao_video_config response = self.client.get("/v2/integrations/health", headers=headers) finally: self.core.N8N_BASE_URL = original_n8n @@ -398,11 +407,20 @@ class ProductionBaselineTests(unittest.TestCase): pass else: self.core.probe_http_json = original_probe_http_json + if original_probe_huobao_video_config is None: + try: + delattr(self.core, "probe_huobao_video_config") + except AttributeError: + pass + else: + self.core.probe_huobao_video_config = original_probe_huobao_video_config self.assertEqual(response.status_code, 200, response.text) payload = response.json() self.assertEqual(payload["n8n"]["deployment_label"], "服务器") self.assertEqual(payload["huobao"]["deployment_label"], "服务器") + self.assertEqual(payload["huobao"]["video_config_route"], "/settings/ai-config -> 视频 -> 火山引擎") + self.assertTrue(payload["huobao"]["video_config_ready"]) self.assertEqual(payload["asr"]["deployment_label"], "Windows") self.assertEqual(payload["live_recorder"]["deployment_label"], "NAS") self.assertEqual(payload["cutvideo"]["deployment_label"], "NAS 隧道") diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index b229f27..e29f503 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -206,13 +206,13 @@ const INTEGRATION_META = { const PIPELINE_GUARDS = { aiVideo: { label: "AI 视频", - openAction: "direct-create-ai-video", + openAction: "open-ai-video", jobAction: "direct-create-ai-video", dependencies: ["n8n", "huobao"] }, realCut: { label: "实拍剪辑", - openAction: "direct-create-real-cut", + openAction: "open-real-cut", jobAction: "direct-create-real-cut", dependencies: ["n8n", "cutvideo"] } @@ -4299,7 +4299,10 @@ function getIntegrationDetail(key) { activeDevice: String(raw?.active_device || ""), activeComputeType: String(raw?.active_compute_type || ""), languageMode: String(raw?.language_mode || ""), - modelName: String(raw?.model_name || "") + modelName: String(raw?.model_name || ""), + videoConfigRoute: String(raw?.video_config_route || ""), + videoConfigCount: Number(raw?.video_config_count || 0), + videoConfigReady: Boolean(raw?.video_config_ready), }; } @@ -4458,6 +4461,16 @@ function getIntegrationCards() { if (detail.modelName) { extra += ` · 当前模型:${detail.modelName}`; } + } else if (key === "huobao") { + const configLabel = detail.videoConfigCount + ? `视频配置 ${detail.videoConfigCount} 条` + : "视频配置未录入"; + extra = `部署:${detail.deploymentLabel || "待确认"} · ${configLabel}`; + if (detail.videoConfigRoute) { + note = detail.videoConfigReady + ? `Huobao 视频配置已就绪:${detail.videoConfigRoute}` + : `Seedance 2.0 走火山视频配置,请在 ${detail.videoConfigRoute} 补齐`; + } } else if (detail.deploymentLabel) { extra = `部署:${detail.deploymentLabel}`; } @@ -5647,6 +5660,7 @@ function renderIntegrationOverviewPanel(options = {}) {
${renderPipelineButton("aiVideo", "primary")} ${renderPipelineButton("realCut")} + ${button("视频录制", "focus-live-recorder-maintenance", "secondary")}
` : ""} @@ -5655,7 +5669,7 @@ function renderIntegrationOverviewPanel(options = {}) {
${cards.map((item) => ` -
+

${escapeHtml(item.meta.label)}

@@ -6523,6 +6537,16 @@ function focusLiveRecorderMaintenance() { }); } +function focusAutomationHealthWorkspace(anchorId = "integration-huobao-anchor") { + appState.automationDetailTab = "health"; + setScreen("automation"); + renderAll(); + window.requestAnimationFrame(() => { + (document.getElementById(anchorId) || document.querySelector('[data-screen="automation"] .integration-panel, [data-screen="automation"] .panel')) + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); +} + function focusReviewWorkspace(reviewId = "") { appState.reviewFocusId = reviewId || ""; setScreen("review"); @@ -7541,7 +7565,7 @@ function renderProductionMobileTaskDeck({ activeTab, activeJobs, failedJobs, rec

${escapeHtml(status.running ? `Live Recorder 正在运行,当前有 ${activeCount} 路活动录制。` : "先确认 Live Recorder 是否在线,再检查录制源和文件。")}

${escapeHtml(status.running ? "运行中" : "待检查")} - ${actionTag("录制维护", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="recorder"`)} + ${actionTag("视频录制", "focus-live-recorder-maintenance")}
`); @@ -7554,12 +7578,12 @@ function renderProductionMobileTaskDeck({ activeTab, activeJobs, failedJobs, rec sourceScreen: "production", sourceActionKey: "production-mobile-recorder-handoff", intentKey: "production_coordination", - title: "继续处理录制维护", - goal: "继续处理录制维护", - summary: "结合录制维护状态给出下一步动作。", + title: "继续处理视频录制", + goal: "继续处理视频录制", + summary: "结合视频录制状态给出下一步动作。", platform: getPreferredPlatform(), platformScope: "single_platform", - planSteps: ["读取录制维护状态", "识别当前阻塞项", "生成下一步处理动作"] + planSteps: ["读取视频录制状态", "识别当前阻塞项", "生成下一步处理动作"] }))}
@@ -7662,13 +7686,13 @@ function renderProductionScreen() { const tabs = [ { value: "queue", label: "生产队列" }, { value: "recovery", label: "失败恢复" }, - { value: "recorder", label: "录制维护" }, + { value: "recorder", label: "视频录制" }, { value: "outputs", label: "作品与产物" } ]; const activeTab = getActiveDetailTab("productionDetailTab", tabs); const productionActionsHtml = isMobileUi - ? `${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: productionHandoffAttrs })}` - : `${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: productionHandoffAttrs })} ${button("去复盘", "goto-review", "primary")} ${button("批量恢复", "batch-recover-jobs", "secondary", { disabledReason: recoverableCount ? "" : "当前没有可恢复的失败任务" })}`; + ? `${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("视频录制", "focus-live-recorder-maintenance", "secondary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: productionHandoffAttrs })}` + : `${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("视频录制", "focus-live-recorder-maintenance", "secondary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: productionHandoffAttrs })} ${button("去复盘", "goto-review", "primary")} ${button("批量恢复", "batch-recover-jobs", "secondary", { disabledReason: recoverableCount ? "" : "当前没有可恢复的失败任务" })}`; return screenShell( "生产中心", "这里已经接上真实任务、失败恢复和知识库文档,适合直接推进生产、恢复和复盘。", @@ -7717,7 +7741,7 @@ function renderProductionScreen() { : activeTab === "outputs" ? `${actionTag("去复盘", "goto-review")} ${actionTag("查看产物", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="outputs"`)}` : activeTab === "recorder" - ? `${actionTag("录制维护", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="recorder"`)} ${actionTag("交给主 Agent", "handoff-to-main-agent", productionHandoffAttrs)}` + ? `${actionTag("视频录制", "focus-live-recorder-maintenance")} ${actionTag("交给主 Agent", "handoff-to-main-agent", productionHandoffAttrs)}` : `${actionTag("批量恢复", "batch-recover-jobs")} ${actionTag("交给主 Agent", "handoff-to-main-agent", productionHandoffAttrs)}` }
@@ -12082,6 +12106,29 @@ function openGenerateCopyAction(defaults = {}) { }); } +function renderAiVideoProviderHintHtml(provider = "doubao") { + const huobao = getIntegrationDetail("huobao"); + const route = huobao.videoConfigRoute || "/settings/ai-config -> 视频 -> 火山引擎"; + const providerLabel = String(provider || "doubao").trim() === "seedance2" ? "Seedance 2.0" : "当前默认视频引擎"; + const configStatus = huobao.videoConfigReady + ? `Huobao 视频配置已就绪${huobao.videoConfigCount ? `(${huobao.videoConfigCount} 条)` : ""}` + : "Huobao 视频配置页当前还没有录入视频引擎配置"; + return ` +
+
+

${escapeHtml(`${providerLabel} 走火山视频配置`)}

+

${escapeHtml(`请在 Huobao 服务里配置火山视频 Key:${route}`)}

+

${escapeHtml("如果不是走页面配置,也可以在 huobao 服务环境变量里覆盖 HUOBAO_VIDEO_BASE_URL / HUOBAO_VIDEO_API_KEY / HUOBAO_VIDEO_MODELS。")}

+
+ ${escapeHtml(configStatus)} + ${huobao.deploymentLabel ? `${escapeHtml(`部署:${huobao.deploymentLabel}`)}` : ""} + 查看火山配置状态 +
+
+
+ `; +} + function openCreateAiVideoAction(defaults = {}) { const guard = getPipelineGuard("aiVideo"); if (!guard.enabled) { @@ -12126,6 +12173,12 @@ function openCreateAiVideoAction(defaults = {}) { value: defaultVideoModel, placeholder: "例如:seedance-2.0-pro", }, + { + name: "videoProviderHint", + label: "Seedance 配置", + type: "html", + html: renderAiVideoProviderHintHtml(defaultVideoProvider), + }, { name: "style", label: "风格", value: defaults.style || recommendCreativeStyle(sourceJob) }, { name: "aspectRatio", @@ -12788,6 +12841,16 @@ document.addEventListener("click", async (event) => { setScreen("production"); return; } + if (name === "focus-live-recorder-maintenance") { + captureMainAgentLandingContext(action, "goto-production"); + focusLiveRecorderMaintenance(); + return; + } + if (name === "focus-huobao-video-config") { + captureMainAgentLandingContext(action, "goto-automation"); + focusAutomationHealthWorkspace("integration-huobao-anchor"); + return; + } if (name === "goto-strategy") { captureMainAgentLandingContext(action, "goto-strategy"); setScreen("strategy"); @@ -13258,15 +13321,7 @@ document.addEventListener("click", async (event) => { } if (name === "open-ai-video") { const fallbackJob = getLatestCompletedProjectJob(); - if (fallbackJob?.id) { - await runDirectWorkbenchAction("create-ai-video", { - busyLabel: "正在创建 AI 视频任务...", - errorTitle: "创建 AI 视频任务失败", - payload: { source_job_id: fallbackJob.id } - }); - return; - } - openCreateAiVideoAction(); + openCreateAiVideoAction(fallbackJob?.id ? { sourceJobId: fallbackJob.id, sourceJob: fallbackJob } : {}); return; } if (name === "direct-create-ai-video") { @@ -13282,15 +13337,7 @@ document.addEventListener("click", async (event) => { } if (name === "open-real-cut") { const fallbackJob = getLatestCompletedProjectJob(); - if (fallbackJob?.id) { - await runDirectWorkbenchAction("create-real-cut", { - busyLabel: "正在创建实拍剪辑任务...", - errorTitle: "创建实拍剪辑任务失败", - payload: { source_job_id: fallbackJob.id } - }); - return; - } - openCreateRealCutAction(); + openCreateRealCutAction(fallbackJob?.id ? { sourceJobId: fallbackJob.id, sourceJob: fallbackJob } : {}); return; } if (name === "direct-create-real-cut") { diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 5a42d17..b7dcf08 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -254,6 +254,7 @@ test("discovery, production, and admin screens use page tabs for heavy content", assert.match(discovery, /renderDetailTabs\("discoveryDetailTab"/); assert.match(production, /renderDetailTabs\("productionDetailTab"/); + assert.match(production, /value: "recorder", label: "视频录制"/); assert.match(admin, /renderDetailTabs\("adminWorkbenchTab"/); assert.match(admin, /renderAdminGovernanceSummaryPanel\(/); assert.match(admin, /覆盖与审计/); @@ -417,8 +418,8 @@ test("pipeline follow-up tags route source-job actions through direct execute ha const pipelineGuards = extractBetween(APP, "const PIPELINE_GUARDS = {", "const ONELINER_INTENT_LABELS = {"); const clickActions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); - assert.match(pipelineGuards, /openAction:\s*"direct-create-ai-video"/); - assert.match(pipelineGuards, /openAction:\s*"direct-create-real-cut"/); + assert.match(pipelineGuards, /openAction:\s*"open-ai-video"/); + assert.match(pipelineGuards, /openAction:\s*"open-real-cut"/); assert.match(pipelineGuards, /jobAction:\s*"direct-create-ai-video"/); assert.match(pipelineGuards, /jobAction:\s*"direct-create-real-cut"/); assert.match(clickActions, /name === "direct-create-ai-video"[\s\S]*if \(action\.dataset\.jobId\) \{[\s\S]*closeActionModal\(\);/); @@ -472,6 +473,7 @@ test("discovery and production screens expose mobile focus cards with next-step test("mobile discovery and production simplify duplicated top-level actions", () => { const discovery = extractBetween(APP, "function renderDiscoveryScreen()", "function renderTrackingScreen()"); const production = extractBetween(APP, "function renderProductionScreen()", "function renderReviewScreen()"); + const clickActions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "navButtons.forEach((button) => {"); assert.match(APP, /function isMobileViewport\(\)/); assert.match(discovery, /const isMobileUi = isMobileViewport\(\);/); @@ -482,8 +484,10 @@ test("mobile discovery and production simplify duplicated top-level actions", () assert.match(discovery, /saveBenchmarkActionName = similarCandidates\.length \? "direct-save-benchmark-link" : "open-benchmark-link"/); assert.match(production, /const isMobileUi = isMobileViewport\(\);/); assert.match(production, /const productionActionsHtml = isMobileUi/); + assert.match(production, /button\("视频录制", "focus-live-recorder-maintenance", "secondary"\)/); assert.match(production, /button\("交给主 Agent", "handoff-to-main-agent"/); assert.match(production, /button\("去复盘", "goto-review", "primary"\)/); + assert.match(clickActions, /name === "focus-live-recorder-maintenance"[\s\S]*captureMainAgentLandingContext\(action,\s*"goto-production"\);[\s\S]*focusLiveRecorderMaintenance\(\)/); }); test("discovery page promotes selected-account actions into direct execute flows", () => { @@ -1080,6 +1084,8 @@ test("playbook and review high-frequency actions now reuse direct execute handle const playbook = extractBetween(APP, "function renderPlaybookScreen()", "function renderProductionScreen()"); const review = extractBetween(APP, "function renderReviewScreen()", "function renderStrategyScreen()"); const clickActions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); + const openAiVideoBlock = extractBetween(clickActions, 'if (name === "open-ai-video") {', 'if (name === "direct-create-ai-video") {'); + const openRealCutBlock = extractBetween(clickActions, 'if (name === "open-real-cut") {', 'if (name === "direct-create-real-cut") {'); assert.match(playbook, /direct-create-assistant/); assert.match(review, /direct-review-draft/); assert.match(APP, /payload: action\.dataset\.jobId \? \{ source_job_id: action\.dataset\.jobId \} : \{\}/); @@ -1091,12 +1097,12 @@ test("playbook and review high-frequency actions now reuse direct execute handle assert.match(clickActions, /name === "open-generate-copy"[\s\S]*openGenerateCopyAction\(\)/); assert.match(clickActions, /name === "open-review-from-job"[\s\S]*runDirectWorkbenchAction\("review-draft"/); assert.match(clickActions, /name === "open-review-from-job"[\s\S]*payload: \{ source_job_id: jobId \}/); - assert.match(clickActions, /name === "open-ai-video"[\s\S]*const fallbackJob = getLatestCompletedProjectJob\(\)/); - assert.match(clickActions, /name === "open-ai-video"[\s\S]*runDirectWorkbenchAction\("create-ai-video"/); - assert.match(clickActions, /name === "open-ai-video"[\s\S]*openCreateAiVideoAction\(\)/); - assert.match(clickActions, /name === "open-real-cut"[\s\S]*const fallbackJob = getLatestCompletedProjectJob\(\)/); - assert.match(clickActions, /name === "open-real-cut"[\s\S]*runDirectWorkbenchAction\("create-real-cut"/); - assert.match(clickActions, /name === "open-real-cut"[\s\S]*openCreateRealCutAction\(\)/); + assert.match(openAiVideoBlock, /const fallbackJob = getLatestCompletedProjectJob\(\)/); + assert.match(openAiVideoBlock, /openCreateAiVideoAction\(/); + assert.doesNotMatch(openAiVideoBlock, /runDirectWorkbenchAction\("create-ai-video"/); + assert.match(openRealCutBlock, /const fallbackJob = getLatestCompletedProjectJob\(\)/); + assert.match(openRealCutBlock, /openCreateRealCutAction\(/); + assert.doesNotMatch(openRealCutBlock, /runDirectWorkbenchAction\("create-real-cut"/); assert.match(clickActions, /name === "open-create-assistant"[\s\S]*const project = getSelectedProject\(\)/); assert.match(clickActions, /name === "open-create-assistant"[\s\S]*runDirectWorkbenchAction\("create-assistant"/); assert.match(clickActions, /name === "open-create-assistant"[\s\S]*openCreateAssistantAction\(\)/); @@ -1105,6 +1111,23 @@ test("playbook and review high-frequency actions now reuse direct execute handle assert.match(clickActions, /name === "open-import-homepage"[\s\S]*openImportHomepageAction\(\)/); }); +test("ai video form explains where Seedance 火山配置 lives", () => { + const clickActions = extractBetween( + APP, + 'document.addEventListener("click", async (event) => {', + 'navButtons.forEach((button) => {' + ); + assert.match(APP, /function renderAiVideoProviderHintHtml\(provider = "doubao"\)/); + assert.match(APP, /Seedance 2\.0 走火山视频配置/); + assert.match(APP, /\/settings\/ai-config/); + assert.match(APP, /视频 -> 火山引擎/); + assert.match(APP, /HUOBAO_VIDEO_API_KEY/); + assert.match(APP, /data-action="focus-huobao-video-config"/); + assert.match(APP, /id="integration-\$\{escapeHtml\(item\.key\)\}-anchor"/); + assert.match(APP, /function focusAutomationHealthWorkspace\(anchorId = "integration-huobao-anchor"\)/); + assert.match(clickActions, /name === "focus-huobao-video-config"[\s\S]*focusAutomationHealthWorkspace\("integration-huobao-anchor"\)/); +}); + test("main agent landing notices expose a compact mobile follow-up strip", () => { const landing = extractBetween(APP, "function renderMainAgentLandingNotice(screenKey)", "function renderEmptyState(title, description)"); assert.match(landing, /mobile-only compact-summary-row/);