feat: productize quota packages and recovery guidance
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled

This commit is contained in:
kris
2026-04-05 06:01:07 +08:00
parent c61c12127f
commit 2cb6d6b1aa
5 changed files with 490 additions and 69 deletions

View File

@@ -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`

View File

@@ -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,
),

View File

@@ -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",

View File

@@ -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 ? `<span class="tag">${escapeHtml(`主要消耗 ${topCategory.category || "usage"}`)}</span>` : `<span class="tag">本周期未产生消耗</span>`
];
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() {
<div class="panel-head">
<div>
<h3>租户额度与审计</h3>
<div class="panel-subtitle">预算、动作配额和最近计量都按租户 + 项目隔离,首屏先看风险和下一步。</div>
<div class="panel-subtitle">${escapeHtml(packageFocus || "预算、动作配额和最近计量都按租户 + 项目隔离,首屏先看风险和下一步。")}</div>
</div>
<div class="task-meta">
<span class="tag ${quota?.enabled === false ? "orange" : "green"}">${escapeHtml(quota?.enabled === false ? "已停用额度保护" : "额度保护开启")}</span>
@@ -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 `
<div class="sheet-html">
<div class="task-item compact">
<h4>${escapeHtml(guidance?.heading || "失败任务处理建议")}</h4>
<p>${escapeHtml(guidance?.summary || recovery?.reason || "先补齐缺失项,再继续推进。")}</p>
<div class="task-meta">
<span class="tag ${recovery?.state === "blocked" ? "red" : "orange"}">${escapeHtml(recovery?.label || "需站内处理")}</span>
<span class="tag">${escapeHtml(guidance?.categoryLabel || recovery?.lineType || recovery?.sourceType || "manual")}</span>
${primaryAction ? actionTag(primaryAction.label, primaryAction.action, primaryAction.attrs) : ""}
${secondaryActions.map((item) => actionTag(item.label, item.action, item.attrs)).join("")}
</div>
</div>
<div class="list">
${steps.map((step) => `
<div class="task-item compact">
<h4>${escapeHtml(step.title)}</h4>
<p>${escapeHtml(step.body)}</p>
<div class="task-meta">
<span class="tag ${step.tone || "orange"}">${escapeHtml(step.label)}</span>
${step.action ? actionTag(step.action.label, step.action.action, step.action.attrs) : ""}
</div>
</div>
`).join("")}
</div>
</div>
`;
}
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: `
<div class="sheet-html">
<div class="task-item compact">
<h4>${escapeHtml(job.title || job.id)}</h4>
<p>${escapeHtml(recovery.reason || "先补回缺失信息,再继续推进这条任务。")}</p>
<div class="task-meta">
<span class="tag ${recovery.state === "blocked" ? "red" : "orange"}">${escapeHtml(recovery.label || "需人工处理")}</span>
${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: ["读取失败任务详情", "判断缺失输入或依赖", "生成下一步恢复建议"]
}))}
</div>
</div>
</div>
`
html: renderRecoverJobGuidanceHtml(job, recovery, guidance)
}
]
});

View File

@@ -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\(\)/);