From 2cb6d6b1aa843ff7b6e3d0df5e72daca7a2c4295 Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 5 Apr 2026 06:01:07 +0800 Subject: [PATCH] feat: productize quota packages and recovery guidance --- CHANGELOG.md | 20 ++ collector-service/app/oneliner_features.py | 185 +++++++++++-- tests/test_main_agent_governance.py | 83 +++++- web/storyforge-web-v4/assets/app.js | 251 +++++++++++++++--- .../tests/workbench-pages.test.mjs | 20 +- 5 files changed, 490 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7497262..6321201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ ## 2026-04-05 +### 套餐档位真正变成服务端额度预设 + +- `/v2/tenant/quota` 现在会把 `trial / growth / scale / custom` 视为真正的服务端套餐档位,而不只是前端标签。 +- 当项目选择 `试用 / 增长 / 规模` 套餐时,后端会自动应用对应的预算、动作池和存储上限,并把规范化后的 `package_title / package_focus / package_defaults / warn_threshold` 一起回写给前端。 +- `自定义套餐` 仍然保留手工数值,适合已经明确成本模型或需要特殊策略的项目。 +- `额度` 页也跟着升级成更像正式产品的展示:会直接显示套餐标题和套餐定位,不再只看到生硬的 `growth/custom` 标签。 + +### 失败任务人工处理流改成站内分场景建议 + +- `生产中心` 里不再用“当前链路没有可自动恢复的模板,建议交给管理员处理”这种笼统提示。 +- 前端现在会按失败原因分流成更具体的站内处理建议: + - 额度拦截 + - 上传素材缺失 + - 实拍剪辑缺少源任务 + - AI 视频缺少源任务 + - 内容源同步缺主页 + - 文本 / 链接缺输入 + - 通用站内处理 +- 每种场景都会直接给出更贴切的 CTA,比如 `去额度 / 重新上传 / 去导入主页 / 看源任务 / 交给主 Agent`,让失败任务不再断在泛泛提示层。 + ### AI 视频链兼容 Seedance 2.0 - `创建 AI 视频任务` 现在新增了 `视频引擎`、`引擎模型`、`镜头语言`、`运动节奏`、`风格约束` 和 `画幅`,可以直接用当前默认引擎或切到 `Seedance 2.0`。 diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py index 7aec827..23a57ed 100644 --- a/collector-service/app/oneliner_features.py +++ b/collector-service/app/oneliner_features.py @@ -137,6 +137,7 @@ class PlatformSkillRollbackRequest(BaseModel): class TenantQuotaRequest(BaseModel): + package_label: str = "" monthly_budget_cents: int = Field(default=0, ge=0) storage_limit_bytes: int = Field(default=0, ge=0) analysis_quota: int = Field(default=0, ge=0) @@ -356,6 +357,48 @@ USAGE_COST_DEFAULTS: dict[str, dict[str, Any]] = { "live_recorder": {"cost_cents": 2, "quota_field": "recorder_quota"}, } +TENANT_QUOTA_PACKAGE_PRESETS: dict[str, dict[str, Any]] = { + "trial": { + "title": "试用套餐", + "description": "适合先跑通主流程的小规模项目,预算和动作池会优先保护试错成本。", + "focus": "先验证项目是否跑得通,再决定是否扩容。", + "warn_threshold": 0.7, + "monthly_budget_cents": 9900, + "storage_limit_bytes": 5 * 1024 * 1024 * 1024, + "analysis_quota": 30, + "copy_quota": 60, + "ai_video_quota": 2, + "real_cut_quota": 1, + "recorder_quota": 4, + }, + "growth": { + "title": "增长套餐", + "description": "适合已经形成固定内容节奏的项目,兼顾分析、文案和视频动作的持续投放。", + "focus": "先把稳定增长跑顺,再看哪里需要单独加码。", + "warn_threshold": 0.8, + "monthly_budget_cents": 49900, + "storage_limit_bytes": 20 * 1024 * 1024 * 1024, + "analysis_quota": 160, + "copy_quota": 320, + "ai_video_quota": 12, + "real_cut_quota": 8, + "recorder_quota": 20, + }, + "scale": { + "title": "规模套餐", + "description": "适合多账号、多批次的量产项目,预算、存储和视频动作都会按高负载配置。", + "focus": "优先保证量产吞吐,再按平台专项做局部优化。", + "warn_threshold": 0.85, + "monthly_budget_cents": 199000, + "storage_limit_bytes": 80 * 1024 * 1024 * 1024, + "analysis_quota": 800, + "copy_quota": 1600, + "ai_video_quota": 40, + "real_cut_quota": 24, + "recorder_quota": 80, + }, +} + ACTION_USAGE_KEYS: dict[str, str] = { "generate-copy": "copy", "review-draft": "review", @@ -386,6 +429,101 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: def _dump(value: Any) -> str: return json.dumps(value or {}, ensure_ascii=False) + def _normalize_package_label(value: Any) -> str: + label = str(value or "").strip().lower() + if not label: + return "custom" + return label if label in TENANT_QUOTA_PACKAGE_PRESETS else "custom" + + def _clamp_warn_threshold(value: Any, fallback: float = 0.8) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + parsed = float(fallback) + if parsed <= 0: + parsed = float(fallback) + return max(0.1, min(parsed, 0.95)) + + def _tenant_quota_package_label(data: dict[str, Any]) -> str: + config = _parse_json(data.get("config_json"), {}) + label = _normalize_package_label(config.get("package_label")) + if label != "custom": + return label + for preset_label, preset_values in TENANT_QUOTA_PACKAGE_PRESETS.items(): + numeric_fields = ( + "monthly_budget_cents", + "storage_limit_bytes", + "analysis_quota", + "copy_quota", + "ai_video_quota", + "real_cut_quota", + "recorder_quota", + ) + if all(int(data.get(field) or 0) == int(preset_values.get(field) or 0) for field in numeric_fields): + return preset_label + return "custom" + + def _tenant_quota_config(data: dict[str, Any]) -> dict[str, Any]: + config = _parse_json(data.get("config_json"), {}) + package_label = _tenant_quota_package_label(data) + config["package_label"] = package_label + preset = TENANT_QUOTA_PACKAGE_PRESETS.get(package_label) + config["warn_threshold"] = _clamp_warn_threshold( + config.get("warn_threshold", preset.get("warn_threshold", 0.8) if preset else 0.8), + preset.get("warn_threshold", 0.8) if preset else 0.8, + ) + if preset: + config.update( + { + "package_title": preset["title"], + "package_description": preset["description"], + "package_focus": preset["focus"], + "package_is_preset": True, + "package_defaults": { + "monthly_budget_cents": int(preset["monthly_budget_cents"]), + "storage_limit_bytes": int(preset["storage_limit_bytes"]), + "analysis_quota": int(preset["analysis_quota"]), + "copy_quota": int(preset["copy_quota"]), + "ai_video_quota": int(preset["ai_video_quota"]), + "real_cut_quota": int(preset["real_cut_quota"]), + "recorder_quota": int(preset["recorder_quota"]), + }, + } + ) + else: + config.update( + { + "package_title": "自定义套餐", + "package_description": "按当前项目的预算、动作池和阶段手动配置套餐。", + "package_focus": "适合已经明确成本模型或需要特殊额度策略的项目。", + "package_is_preset": False, + "package_defaults": {}, + } + ) + return config + + def _tenant_quota_values(request: TenantQuotaRequest, package_label: str) -> dict[str, int]: + preset = TENANT_QUOTA_PACKAGE_PRESETS.get(package_label) + if preset: + return { + "monthly_budget_cents": int(preset["monthly_budget_cents"]), + "storage_limit_bytes": int(preset["storage_limit_bytes"]), + "analysis_quota": int(preset["analysis_quota"]), + "copy_quota": int(preset["copy_quota"]), + "ai_video_quota": int(preset["ai_video_quota"]), + "real_cut_quota": int(preset["real_cut_quota"]), + "recorder_quota": int(preset["recorder_quota"]), + } + return { + "monthly_budget_cents": int(request.monthly_budget_cents or 0), + "storage_limit_bytes": int(request.storage_limit_bytes or 0), + "analysis_quota": int(request.analysis_quota or 0), + "copy_quota": int(request.copy_quota or 0), + "ai_video_quota": int(request.ai_video_quota or 0), + "real_cut_quota": int(request.real_cut_quota or 0), + "recorder_quota": int(request.recorder_quota or 0), + } + def _bool_flag(value: Any) -> bool: if isinstance(value, bool): return value @@ -2743,10 +2881,12 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: def _tenant_quota_payload(row: dict[str, Any] | None, *, usage: dict[str, Any] | None = None) -> dict[str, Any]: data = row or {} + config = _tenant_quota_config(data) return { "id": data.get("id", ""), "user_id": data.get("user_id", ""), "project_id": data.get("project_id", ""), + "package_label": config["package_label"], "monthly_budget_cents": int(data.get("monthly_budget_cents") or 0), "storage_limit_bytes": int(data.get("storage_limit_bytes") or 0), "analysis_quota": int(data.get("analysis_quota") or 0), @@ -2755,7 +2895,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: "real_cut_quota": int(data.get("real_cut_quota") or 0), "recorder_quota": int(data.get("recorder_quota") or 0), "enabled": True if row is None else _bool_flag(data.get("enabled", 1)), - "config": _parse_json(data.get("config_json"), {}), + "config": config, "usage": usage or {}, "created_at": data.get("created_at", ""), "updated_at": data.get("updated_at", ""), @@ -7244,6 +7384,17 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: project = _resolve_project(account, project_id or None) existing = _get_tenant_quota_row(account, project_id=project["id"]) timestamp = now() + request_config = dict(request.config or {}) + requested_package_label = _normalize_package_label(request.package_label or request_config.get("package_label")) + package_label = requested_package_label if requested_package_label in TENANT_QUOTA_PACKAGE_PRESETS else "custom" + warn_threshold_value = _clamp_warn_threshold( + request_config.get("warn_threshold", 0.8), + TENANT_QUOTA_PACKAGE_PRESETS.get(package_label, {}).get("warn_threshold", 0.8), + ) + normalized_config = dict(request_config) + normalized_config["package_label"] = package_label + normalized_config["warn_threshold"] = warn_threshold_value + quota_values = _tenant_quota_values(request, package_label) if existing: legacy.db.execute( """ @@ -7253,15 +7404,15 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: WHERE id = ? """, ( - request.monthly_budget_cents, - request.storage_limit_bytes, - request.analysis_quota, - request.copy_quota, - request.ai_video_quota, - request.real_cut_quota, - request.recorder_quota, + quota_values["monthly_budget_cents"], + quota_values["storage_limit_bytes"], + quota_values["analysis_quota"], + quota_values["copy_quota"], + quota_values["ai_video_quota"], + quota_values["real_cut_quota"], + quota_values["recorder_quota"], 1 if request.enabled else 0, - _dump(request.config), + _dump(normalized_config), timestamp, existing["id"], ), @@ -7279,15 +7430,15 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: quota_id, account["id"], project["id"], - request.monthly_budget_cents, - request.storage_limit_bytes, - request.analysis_quota, - request.copy_quota, - request.ai_video_quota, - request.real_cut_quota, - request.recorder_quota, + quota_values["monthly_budget_cents"], + quota_values["storage_limit_bytes"], + quota_values["analysis_quota"], + quota_values["copy_quota"], + quota_values["ai_video_quota"], + quota_values["real_cut_quota"], + quota_values["recorder_quota"], 1 if request.enabled else 0, - _dump(request.config), + _dump(normalized_config), timestamp, timestamp, ), diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py index d3af014..7a1000f 100644 --- a/tests/test_main_agent_governance.py +++ b/tests/test_main_agent_governance.py @@ -787,27 +787,92 @@ class MainAgentGovernanceTests(unittest.TestCase): self.assertEqual(saved_registry["config"]["tone"], "sales") self.assertEqual(saved_registry["source"], "override") + growth_preset = { + "monthly_budget_cents": 49900, + "storage_limit_bytes": 21474836480, + "analysis_quota": 160, + "copy_quota": 320, + "ai_video_quota": 12, + "real_cut_quota": 8, + "recorder_quota": 20, + } quota_response = self.client.put( "/v2/tenant/quota", headers=self.ctx["member_headers"], params={"project_id": self.ctx["project_id"]}, json={ "enabled": True, - "monthly_budget_cents": 12800, - "storage_limit_bytes": 987654321, - "analysis_quota": 21, - "copy_quota": 13, + "monthly_budget_cents": 1, + "storage_limit_bytes": 2, + "analysis_quota": 3, + "copy_quota": 4, "ai_video_quota": 5, - "real_cut_quota": 4, - "recorder_quota": 9, - "config": {"warn_threshold": 0.8}, + "real_cut_quota": 6, + "recorder_quota": 7, + "config": {"package_label": "growth", "warn_threshold": 0.8, "custom_note": "keep"}, }, ) self.assertEqual(quota_response.status_code, 200, quota_response.text) quota_payload = quota_response.json() - self.assertEqual(quota_payload["monthly_budget_cents"], 12800) - self.assertEqual(quota_payload["analysis_quota"], 21) + self.assertEqual(quota_payload["package_label"], "growth") + self.assertEqual(quota_payload["monthly_budget_cents"], growth_preset["monthly_budget_cents"]) + self.assertEqual(quota_payload["storage_limit_bytes"], growth_preset["storage_limit_bytes"]) + self.assertEqual(quota_payload["analysis_quota"], growth_preset["analysis_quota"]) + self.assertEqual(quota_payload["copy_quota"], growth_preset["copy_quota"]) + self.assertEqual(quota_payload["ai_video_quota"], growth_preset["ai_video_quota"]) + self.assertEqual(quota_payload["real_cut_quota"], growth_preset["real_cut_quota"]) + self.assertEqual(quota_payload["recorder_quota"], growth_preset["recorder_quota"]) + self.assertEqual(quota_payload["config"]["package_label"], "growth") self.assertEqual(quota_payload["config"]["warn_threshold"], 0.8) + self.assertEqual(quota_payload["config"]["package_title"], "增长套餐") + self.assertTrue(quota_payload["config"]["package_is_preset"]) + self.assertEqual(quota_payload["config"]["custom_note"], "keep") + + quota_get_response = self.client.get( + "/v2/tenant/quota", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + ) + self.assertEqual(quota_get_response.status_code, 200, quota_get_response.text) + quota_get_payload = quota_get_response.json() + self.assertEqual(quota_get_payload["package_label"], "growth") + self.assertEqual(quota_get_payload["monthly_budget_cents"], growth_preset["monthly_budget_cents"]) + self.assertEqual(quota_get_payload["config"]["package_label"], "growth") + self.assertEqual(quota_get_payload["config"]["warn_threshold"], 0.8) + self.assertEqual(quota_get_payload["config"]["package_title"], "增长套餐") + + custom_response = self.client.put( + "/v2/tenant/quota", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + json={ + "enabled": False, + "monthly_budget_cents": 9100, + "storage_limit_bytes": 111, + "analysis_quota": 22, + "copy_quota": 33, + "ai_video_quota": 44, + "real_cut_quota": 55, + "recorder_quota": 66, + "config": {"package_label": "custom", "warn_threshold": 0.55, "custom_note": "manual"}, + }, + ) + self.assertEqual(custom_response.status_code, 200, custom_response.text) + custom_payload = custom_response.json() + self.assertEqual(custom_payload["package_label"], "custom") + self.assertEqual(custom_payload["monthly_budget_cents"], 9100) + self.assertEqual(custom_payload["storage_limit_bytes"], 111) + self.assertEqual(custom_payload["analysis_quota"], 22) + self.assertEqual(custom_payload["copy_quota"], 33) + self.assertEqual(custom_payload["ai_video_quota"], 44) + self.assertEqual(custom_payload["real_cut_quota"], 55) + self.assertEqual(custom_payload["recorder_quota"], 66) + self.assertFalse(custom_payload["enabled"]) + self.assertEqual(custom_payload["config"]["package_label"], "custom") + self.assertEqual(custom_payload["config"]["warn_threshold"], 0.55) + self.assertEqual(custom_payload["config"]["package_title"], "自定义套餐") + self.assertFalse(custom_payload["config"]["package_is_preset"]) + self.assertEqual(custom_payload["config"]["custom_note"], "manual") usage_response = self.client.get( "/v2/tenant/usage", diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 9610a10..900df2b 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -4155,7 +4155,8 @@ function renderTenantQuotaPanel() { (quota?.recorder_quota || 0) > 0 ); const usageCount = recentItems.length; - const packageLabel = String(quotaConfig.package_label || "").trim() || (hasHardLimit ? "自定义套餐" : "未设套餐"); + const packageLabel = String(quotaConfig.package_title || quotaConfig.package_label || "").trim() || (hasHardLimit ? "自定义套餐" : "未设套餐"); + const packageFocus = String(quotaConfig.package_focus || "").trim(); const warnThreshold = Number(quotaConfig.warn_threshold ?? 0.8); const quotaTaskTitle = quota?.storage_over_limit ? "先处理存储超限" @@ -4184,7 +4185,7 @@ function renderTenantQuotaPanel() { topCategory ? `${escapeHtml(`主要消耗 ${topCategory.category || "usage"}`)}` : `本周期未产生消耗` ]; const cards = [ - { label: "套餐档位", value: packageLabel, sub: `预警阈值 ${formatNumber((warnThreshold || 0.8) * 100)}%` }, + { label: "套餐档位", value: packageLabel, sub: packageFocus || `预警阈值 ${formatNumber((warnThreshold || 0.8) * 100)}%` }, { label: "预算", value: `${formatNumber((quota?.monthly_budget_cents || 0) / 100)} 元`, sub: `已用 ${formatNumber((usage?.total_cost_cents || 0) / 100)} 元` }, { label: "分析配额", value: formatNumber(quota?.analysis_quota || 0), sub: `已用 ${formatNumber(categories.analysis?.quantity || 0)}` }, { label: "文案配额", value: formatNumber(quota?.copy_quota || 0), sub: `已用 ${formatNumber(categories.copy?.quantity || 0)}` }, @@ -4197,7 +4198,7 @@ function renderTenantQuotaPanel() {

租户额度与审计

-
预算、动作配额和最近计量都按租户 + 项目隔离,首屏先看风险和下一步。
+
${escapeHtml(packageFocus || "预算、动作配额和最近计量都按租户 + 项目隔离,首屏先看风险和下一步。")}
${escapeHtml(quota?.enabled === false ? "已停用额度保护" : "额度保护开启")} @@ -8250,14 +8251,212 @@ function getJobRecoverability(job) { return { ...base, state: "manual", - label: "需人工处理", - reason: "当前链路没有可自动恢复的模板,建议交给管理员处理。", + label: "需站内处理", + reason: "当前链路没有命中可自动恢复模板,先在站内补齐缺失的素材、源任务、主页或额度,再继续推进。", recoverable: false, - actionLabel: "管理员处理", + actionLabel: "去生产中心", actionKey: "goto-production" }; } +function getRecoverJobGuidance(job, recovery) { + const jobId = String(job?.id || "").trim(); + const jobTitle = String(job?.title || jobId || "失败任务").trim(); + const sourceJobId = String(recovery?.sourceJobId || "").trim(); + const jobDetailAction = jobId + ? { label: "看任务详情", action: "open-job-detail", attrs: `data-job-id="${escapeHtml(jobId)}"` } + : null; + const sourceDetailAction = sourceJobId + ? { label: "看源任务", action: "open-job-detail", attrs: `data-job-id="${escapeHtml(sourceJobId)}"` } + : jobDetailAction; + const productionAction = { label: "去生产中心", action: "goto-production", attrs: "" }; + const quotaAction = { label: "去额度", action: "open-tenant-quota", attrs: "" }; + const uploadAction = { label: "重新上传", action: "open-upload-video", attrs: "" }; + const importHomepageAction = { label: "去导入主页", action: "open-import-homepage", attrs: "" }; + const handoffAction = { + label: "交给主 Agent", + action: "handoff-to-main-agent", + attrs: buildMainAgentHandoffAttrs({ + sourceScreen: "production", + sourceActionKey: "recover-job-handoff", + intentKey: "custom", + title: `处理失败任务 ${jobTitle}`, + goal: "结合失败原因和当前任务上下文,给出下一步恢复建议", + summary: "主 Agent 会先判断这条任务缺的是素材、额度还是源任务,再给出具体处理路径。", + planSteps: ["读取失败任务详情", "判断缺失输入或依赖", "生成下一步恢复建议"] + }) + }; + const steps = []; + const addStep = (title, body, label, action, tone = "orange") => { + steps.push({ title, body, label, action, tone }); + }; + let primaryAction = sourceDetailAction || productionAction; + const secondaryActions = []; + const pushSecondaryAction = (action) => { + if (!action) return; + if (primaryAction && primaryAction.action === action.action && primaryAction.attrs === action.attrs) return; + if (secondaryActions.some((item) => item.action === action.action && item.attrs === action.attrs)) return; + secondaryActions.push(action); + }; + + let heading = "先补链路再继续"; + let summary = recovery?.reason || "当前链路没有命中自动恢复模板,先补齐缺失项后再继续。"; + + if (recovery?.state === "blocked") { + heading = "先补额度再恢复"; + summary = recovery.reason || "当前任务被额度拦截,先补完额度后再回到生产中心重试。"; + addStep("打开额度面板", "查看本周期剩余额度和拦截原因。", "去额度", quotaAction, "orange"); + addStep("补完额度后回到生产中心", "额度恢复后,再回到生产中心重新发起这条任务。", "去生产中心", productionAction, "blue"); + pushSecondaryAction(productionAction); + pushSecondaryAction(handoffAction); + primaryAction = quotaAction; + return { + heading, + summary, + primaryAction: quotaAction, + secondaryActions, + steps, + categoryLabel: "额度拦截" + }; + } + + if (recovery?.sourceType === "upload_video") { + heading = "先补上传素材"; + summary = recovery.reason || "上传素材缺失时,先重新上传再继续恢复。"; + addStep("重新上传原始素材", "补回原文件后,这条任务才能再次入队。", "重新上传", uploadAction, "orange"); + addStep("如果不确定缺什么,先看任务详情", "任务详情里会保留失败原因和素材信息。", "看任务详情", jobDetailAction, "blue"); + pushSecondaryAction(jobDetailAction); + pushSecondaryAction(handoffAction); + primaryAction = uploadAction; + return { + heading, + summary, + primaryAction: uploadAction, + secondaryActions, + steps, + categoryLabel: "素材缺失" + }; + } + + if (recovery?.lineType === "real_cut") { + heading = "先补实拍源任务"; + summary = recovery.reason || "实拍剪辑缺少源任务时,先补回源任务再重跑。"; + addStep("打开源任务", "确认源任务是否已完成,或者是否需要先补回源任务。", "看源任务", sourceDetailAction, "orange"); + addStep("补完源任务后再发起实拍剪辑", "源任务恢复后,再回到生产中心继续处理。", "去生产中心", productionAction, "blue"); + pushSecondaryAction(productionAction); + pushSecondaryAction(handoffAction); + primaryAction = sourceDetailAction; + return { + heading, + summary, + primaryAction: sourceDetailAction, + secondaryActions, + steps, + categoryLabel: "实拍剪辑" + }; + } + + if (recovery?.lineType === "ai_video") { + heading = "先补 AI 视频源任务"; + summary = recovery.reason || "AI 视频缺少源任务时,先补回源任务再重跑。"; + addStep("打开源任务", "确认源任务和当前 brief 是否需要一起补齐。", "看源任务", sourceDetailAction, "orange"); + addStep("源任务补齐后再继续 AI 视频", "回到生产中心重新发起这条任务。", "去生产中心", productionAction, "blue"); + pushSecondaryAction(productionAction); + pushSecondaryAction(handoffAction); + primaryAction = sourceDetailAction; + return { + heading, + summary, + primaryAction: sourceDetailAction, + secondaryActions, + steps, + categoryLabel: "AI 视频" + }; + } + + if (recovery?.sourceType === "content_source_sync") { + heading = "先补主页再同步"; + summary = recovery.reason || "内容源同步缺少主页时,先补回主页再触发同步。"; + addStep("去导入主页", "先把主页地址补回,再重新触发内容源同步。", "去导入主页", importHomepageAction, "orange"); + addStep("如果主页已存在,直接看任务详情", "任务详情里会保留失败时的输入和状态。", "看任务详情", jobDetailAction, "blue"); + pushSecondaryAction(jobDetailAction); + pushSecondaryAction(handoffAction); + primaryAction = importHomepageAction; + return { + heading, + summary, + primaryAction: importHomepageAction, + secondaryActions, + steps, + categoryLabel: "主页缺失" + }; + } + + if (recovery?.sourceType === "text" || recovery?.sourceType === "video_link") { + heading = "先补输入再恢复"; + summary = recovery.reason || "原始输入缺失时,先补回输入再继续恢复。"; + addStep("打开任务详情补输入", "把原始文本或视频链接补回后,再重新发起恢复。", "看任务详情", jobDetailAction, "orange"); + addStep("如果想让系统代办,交给主 Agent", "主 Agent 会先判断应该补哪一段输入,再给出下一步。", "交给主 Agent", handoffAction, "blue"); + pushSecondaryAction(handoffAction); + pushSecondaryAction(productionAction); + primaryAction = jobDetailAction || productionAction; + return { + heading, + summary, + primaryAction: jobDetailAction || productionAction, + secondaryActions, + steps, + categoryLabel: "输入缺失" + }; + } + + addStep("打开任务详情", "先确认这条失败任务缺的是素材、源任务、主页还是额度。", "看任务详情", jobDetailAction, "orange"); + addStep("回到生产中心继续处理", "补完信息后回到生产中心重新判断是否能恢复。", "去生产中心", productionAction, "blue"); + addStep("交给主 Agent", "如果不确定该补哪一步,可以让主 Agent 先给出处理建议。", "交给主 Agent", handoffAction, "blue"); + pushSecondaryAction(jobDetailAction); + pushSecondaryAction(handoffAction); + return { + heading, + summary, + primaryAction: primaryAction || productionAction, + secondaryActions, + steps, + categoryLabel: "站内处理" + }; +} + +function renderRecoverJobGuidanceHtml(job, recovery, guidance) { + const primaryAction = guidance?.primaryAction || null; + const secondaryActions = safeArray(guidance?.secondaryActions); + const steps = safeArray(guidance?.steps); + return ` +
+
+

${escapeHtml(guidance?.heading || "失败任务处理建议")}

+

${escapeHtml(guidance?.summary || recovery?.reason || "先补齐缺失项,再继续推进。")}

+
+ ${escapeHtml(recovery?.label || "需站内处理")} + ${escapeHtml(guidance?.categoryLabel || recovery?.lineType || recovery?.sourceType || "manual")} + ${primaryAction ? actionTag(primaryAction.label, primaryAction.action, primaryAction.attrs) : ""} + ${secondaryActions.map((item) => actionTag(item.label, item.action, item.attrs)).join("")} +
+
+
+ ${steps.map((step) => ` +
+

${escapeHtml(step.title)}

+

${escapeHtml(step.body)}

+
+ ${escapeHtml(step.label)} + ${step.action ? actionTag(step.action.label, step.action.action, step.action.attrs) : ""} +
+
+ `).join("")} +
+
+ `; +} + function getJobRecoveryRequest(job) { const recovery = getJobRecoverability(job); if (!recovery.recoverable) { @@ -10047,7 +10246,7 @@ function openTenantQuotaAction() { const quotaConfig = quota.config || {}; openActionModal({ title: "编辑租户额度", - description: "当前额度按租户 + 项目隔离,用于商业化预算、动作配额和存储保护。", + description: "当前额度按租户 + 项目隔离;选择预设套餐时,服务端会自动应用对应预算、动作池和存储保护。", submitLabel: "保存额度", fields: [ { name: "enabled", label: "启用额度保护", type: "checkbox", value: quota.enabled !== false }, @@ -10070,6 +10269,7 @@ function openTenantQuotaAction() { const saved = await storyforgeFetch(`/v2/tenant/quota?project_id=${encodeURIComponent(project.id)}`, { method: "PUT", body: { + package_label: String(values.packageLabel || "custom"), enabled: Boolean(values.enabled), monthly_budget_cents: Number(values.monthlyBudgetCents || 0), storage_limit_bytes: Number(values.storageLimitBytes || 0), @@ -10687,45 +10887,16 @@ function openRecoverJobAction(jobId) { } const recovery = getJobRecoverability(job); if (!recovery.recoverable) { - const nextActionTag = (() => { - if (recovery.actionKey === "open-job-detail") { - const targetJobId = recovery.sourceJobId || job.id; - return actionTag(recovery.actionLabel || "查看详情", "open-job-detail", `data-job-id="${escapeHtml(targetJobId)}"`); - } - if (recovery.actionKey) { - return actionTag(recovery.actionLabel || "继续处理", recovery.actionKey); - } - return ""; - })(); + const guidance = getRecoverJobGuidance(job, recovery); openActionModal({ - title: "当前任务需要先处理依赖", - description: recovery.reason || "这条任务需要先补信息或转人工处理。", + title: "失败任务处理建议", + description: guidance.summary || recovery.reason || "先补信息,再继续推进。", hideSubmit: true, fields: [ { type: "html", label: "处理建议", - html: ` -
-
-

${escapeHtml(job.title || job.id)}

-

${escapeHtml(recovery.reason || "先补回缺失信息,再继续推进这条任务。")}

-
- ${escapeHtml(recovery.label || "需人工处理")} - ${nextActionTag} - ${actionTag("交给主 Agent", "handoff-to-main-agent", buildMainAgentHandoffAttrs({ - sourceScreen: "production", - sourceActionKey: "recover-job-handoff", - intentKey: "custom", - title: `处理失败任务 ${job.title || job.id}`, - goal: "结合失败原因和当前任务上下文,给出下一步恢复建议", - summary: "主 Agent 会先判断这条任务缺的是素材、额度还是源任务,再给出具体处理路径。", - planSteps: ["读取失败任务详情", "判断缺失输入或依赖", "生成下一步恢复建议"] - }))} -
-
-
- ` + html: renderRecoverJobGuidanceHtml(job, recovery, guidance) } ] }); diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index c1c7d2a..86351be 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -369,6 +369,7 @@ test("tracking refresh and top-video analysis flows expose async feedback inside assert.doesNotMatch(topVideoAction, /当前后端暂不支持.*高分作品批量分析/s); assert.doesNotMatch(topVideoAction, /当前实例未提供/); assert.doesNotMatch(jobRecoverability, /暂时无法自动恢复/); + assert.doesNotMatch(jobRecoverability, /当前链路没有可自动恢复的模板/); assert.ok(!jobRecoverability.includes('backendSupports("/v2/explore/jobs/{job_id}/retry") && uploadedPath')); assert.doesNotMatch(recoveryAction, /暂不支持自动恢复|暂不支持恢复/); assert.ok(!recoveryAction.includes('if (backendSupports("/v2/explore/jobs/{job_id}/retry"))')); @@ -1046,13 +1047,26 @@ test("recovery and copy actions continue into the most useful result view", () = const singleRecover = extractBetween(APP, "function openRecoverJobAction(jobId)", "function openBatchRecoverJobsAction()"); const batchRecover = extractBetween(APP, "function openBatchRecoverJobsAction()", "function openGenerateCopyAction(defaults = {})"); const generateCopy = extractBetween(APP, "function openGenerateCopyAction(defaults = {})", "function openCreateAiVideoAction(defaults = {})"); + const recoveryGuide = extractBetween(APP, "function getRecoverJobGuidance(job, recovery) {", "function getJobRecoveryRequest(job) {"); assert.match(APP, /function focusProductionDetailTab\(tabValue\)/); assert.match(APP, /function focusRecentGeneratedCopy\(\)/); assert.match(singleRecover, /if \(result\?\.created\?\.id\) \{\s*openJobDetailAction\(result\.created\.id\);/); assert.match(singleRecover, /focusProductionDetailTab\("recovery"\)/); - assert.match(singleRecover, /当前任务需要先处理依赖/); - assert.match(singleRecover, /recover-job-handoff/); - assert.match(singleRecover, /actionTag\(recovery\.actionLabel \|\| "查看详情", "open-job-detail"/); + assert.match(singleRecover, /失败任务处理建议/); + assert.match(singleRecover, /getRecoverJobGuidance\(job, recovery\)/); + assert.match(singleRecover, /renderRecoverJobGuidanceHtml\(job, recovery, guidance\)/); + assert.match(singleRecover, /sheet-html/); + assert.match(singleRecover, /先补信息,再继续推进/); + assert.match(recoveryGuide, /state === "blocked"/); + assert.match(recoveryGuide, /upload_video/); + assert.match(recoveryGuide, /real_cut/); + assert.match(recoveryGuide, /ai_video/); + assert.match(recoveryGuide, /content_source_sync/); + assert.match(recoveryGuide, /text/); + assert.match(recoveryGuide, /video_link/); + assert.match(recoveryGuide, /去导入主页/); + assert.match(recoveryGuide, /交给主 Agent/); + assert.match(recoveryGuide, /recover-job-handoff/); assert.match(batchRecover, /if \(successes\[0\]\?\.result\?\.created\?\.id\) \{\s*openJobDetailAction\(successes\[0\]\.result\.created\.id\);/); assert.match(batchRecover, /focusProductionDetailTab\("recovery"\)/); assert.match(generateCopy, /focusRecentGeneratedCopy\(\)/);