diff --git a/core/backend/apps/ai/providers/openai_compatible.py b/core/backend/apps/ai/providers/openai_compatible.py index 9c9078d..a6fbed8 100644 --- a/core/backend/apps/ai/providers/openai_compatible.py +++ b/core/backend/apps/ai/providers/openai_compatible.py @@ -30,6 +30,26 @@ def _downscale_ref_for_edit(data: bytes, content_type: str, max_edge: int = 2048 return data, content_type +def _raise_with_body(response: requests.Response) -> None: + """非 2xx 时抛出**含响应体**的错误。中转站(yunqi 等)的真实原因(如 OpenAI 风格 + {"error":{"code":"moderation_blocked","message":"...safety_violations=[sexual]..."}})只在 body 里; + 原来的 raise_for_status() 只带状态行「400 Bad Request for url」→ 真因长期不可见。这里把 body 一并抛出, + 供上层记日志 + 翻成前端友好提示。""" + if response.ok: + return + detail = "" + try: + data = response.json() + err = data.get("error") if isinstance(data, dict) else None + if isinstance(err, dict): + detail = " ".join(str(err.get(k)) for k in ("code", "message") if err.get(k)).strip() + if not detail: + detail = str(data)[:500] + except Exception: # noqa: BLE001 — body 非 JSON / 解析失败:退回纯文本截断 + detail = (response.text or "")[:500] + raise requests.HTTPError(f"{response.status_code} {detail}".strip(), response=response) + + class OpenAICompatibleProvider(VolcanoArkProvider): """通用 OpenAI 兼容中转站适配器(tokenssr / yunqi / 任意 New-API 网关)。 @@ -80,7 +100,7 @@ class OpenAICompatibleProvider(VolcanoArkProvider): json=body, timeout=300, ) - response.raise_for_status() + _raise_with_body(response) return response.json() def image_edit( @@ -121,5 +141,5 @@ class OpenAICompatibleProvider(VolcanoArkProvider): data=data, timeout=300, ) - response.raise_for_status() + _raise_with_body(response) return response.json() diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 22d0de1..746b48c 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1398,7 +1398,10 @@ def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra def _is_transient_error(exc: Exception) -> bool: """网络抖动/超时类瞬时错误(可重试),区别于内容审核拦截、参数非法等确定性失败。 - 中转站(tokenssr 等)偶发 Read timeout / 连接重置会无谓掐掉单帧,这类才重试。""" + 中转站(tokenssr 等)偶发 Read timeout / 连接重置会无谓掐掉单帧,这类才重试。 + ★ 不重试 400:实测故事板的 400 主因是 gpt-image-2 内容审核拦截(moderation_blocked, + safety_violations=[sexual],如脚本写到「蕾丝胸罩/抚摸/贴身」)—— 同一画面/文案重试还是被拦, + 重试只会拖延报错、空耗调用。这类应「快速失败 + 友好提示」让用户改提示词,而非闷头重试。""" msg = str(exc).lower() return any( k in msg @@ -1407,6 +1410,25 @@ def _is_transient_error(exc: Exception) -> bool: ) +def friendly_generation_error(raw: str) -> str: + """把模型/中转站的原始报错翻成给用户的中文友好提示(前端直接展示)。原始报错仍记进 AITask 供排查。 + 覆盖:内容审核拦截 / 超时 / 限流 / 凭证 / 参考图被拒 等;命不中给通用兜底。""" + s = (raw or "").lower() + if any(k in s for k in ("moderation_blocked", "safety system", "safety_violation", "content_policy", "content policy")): + return "画面或文案被内容审核拦截(疑似敏感内容)。请调整脚本措辞(如避免「胸罩 / 内衣 / 抚摸 / 贴身」等直白表述,改用「产品 / 包装展示」),或更换参考图后重试。" + if any(k in s for k in ("timed out", "timeout", "read timed out")): + return "生成超时,可能是网络波动或模型繁忙,请稍后重试。" + if any(k in s for k in ("429", "too many requests", "rate limit", "forbidden", "403")): + return "请求过于频繁,模型暂时限流,请稍候片刻再重试。" + if any(k in s for k in ("api_key", "unauthorized", "401")): + return "图像服务凭证异常,请联系管理员处理。" + if any(k in s for k in ("invalid_image", "invalid image", "image_file")): + return "参考图未被模型接受(可能格式/尺寸问题),请更换参考图后重试。" + if any(k in s for k in ("400", "bad request")): + return "生成请求被模型拒绝,请调整提示词或更换参考图后重试。" + return "生成失败,请重试;若多次失败请联系技术支持。" + + def _call_image_with_retry(fn, *, attempts: int = 2, base_delay: float = 2.0): """对一次出图网络调用做有界重试:仅瞬时错误重试(指数退避),确定性失败立即抛出。 出图无副作用(失败=没拿到图),重试安全;成功一次即返回。 @@ -1487,14 +1509,16 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None: transaction.on_commit(lambda a=asset: submit_asset_for_review(a)) except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务+shot 失败供 poll 上报 + raw = str(exc) + hint = friendly_generation_error(raw) # 翻成前端友好提示;原始报错(含 yunqi body)留在 task 供排查 task.status = AITask.Status.FAILED - task.error_message = str(exc) + task.error_message = raw[:2000] task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) - release_credit(reservation=reservation, reason=str(exc)) - # 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 错误供前端显示 + release_credit(reservation=reservation, reason=raw[:200]) + # 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 友好提示供前端显示 StoryboardShot.objects.filter(id=shot.id).update( - status=StoryboardShot.Status.FAILED, error_message=str(exc), updated_at=timezone.now()) + status=StoryboardShot.Status.FAILED, error_message=hint, updated_at=timezone.now()) finally: connections.close_all() # 释放该线程的 DB 连接 diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 45beecb..8a61d31 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -3150,6 +3150,12 @@ export function PipelinePage(props: {
场 {idx + 1} {shotBusy(shot) &&
场 {idx + 1}
@@ -3195,6 +3201,13 @@ export function PipelinePage(props: { : 待生成) : 未生成} + {/* 失败原因·友好提示(后端已把 yunqi 真因翻成中文,如内容审核拦截 → 提示改措辞) */} + {sbActiveShot?.status === "failed" && !activeBusy && sbActiveShot.error_message && ( +
+ + {sbActiveShot.error_message} +
+ )}
每个场一张分镜图 · 可单独「重跑本场」只重出这一张,每场各自留历史版本,互不影响。
// 整张风格提示词(重跑时生效,可编辑)