fix: 修复资产/视频/三视图三处 bug + 脚本模型切 YunQi gemini

1. 视频「全部生成」串行短路:任一段提交失败就 break 致后面段不提交
   (4 个框只跑 2 个)→ 改 Promise.allSettled 并发,单段失败不拖累其余,
   部分失败也刷新并如实报告。(App.tsx)
2. 基础资产/三视图 AI 出图偶发失败「生成不出来」:中转站瞬时抖动
   (网络/5xx/429/空返回)一次失败即退费 → 加退避重试 3 次,永久错误
   (4xx 内容违规)仍立即退费。(services.py run_base_asset_task/run_triview_task)
3. 商品三视图无法用已有素材:商品卡加「使用已有素材」,选商品已有图 /
   上传本地图直接 attach 成三视图(不走平台 AI 生成、不计费)。(pipeline.tsx)
4. 脚本助手:tokenssr 欠费致 GPT-5.5/Gemini 全挂 → 文本模型切 YunQi。
   实测 YunQi gpt-5.5 频道扛不住重型结构化任务(skill 全提示词仅 1/5 成功、
   常吐空无 JSON),gemini-3.1-pro 5/5 稳定 → 默认改 gemini,停用 gpt-5.5。
   按「一 provider 一 key」拆 yunqi_gpt / yunqi_gemini 两 provider。
   (.env / settings / 迁移 0017+0018)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-25 12:00:03 +08:00
co-authored by Claude Opus 4.8
parent 8222a46202
commit dea9dbb3ed
7 changed files with 274 additions and 20 deletions
+19 -6
View File
@@ -916,7 +916,7 @@ export function App() {
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")}
onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")}
onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已替换为所选演员")}
onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已采用所选素材")}
onGenerateTriview={async (portraitAssetId) => {
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成");
@@ -940,11 +940,24 @@ export function App() {
action(async () => {
// 「全部重跑」要把已完成的也重跑(否则全成功的项目点了等于没点);只跳过在途(running/queued)避免重复提交
const targets = pipelineProject.video_segments.filter((segment) => !["running", "queued"].includes(segment.status));
for (const segment of targets) {
await api.submitVideo(pipelineProject.id, {
video_segment_id: segment.id,
prompt: `${prompt}${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds}`
});
// ★ 并发提交,且单段失败绝不拖累其余段。旧实现用 for+await 串行,任一段抛错(如人脸需走素材库
// 的 502、中转限流)整个循环就 break → 后面的段永不提交,前端出现「4 个框只有 2 个在跑」。
// 改 allSettled:每段各自独立提交,失败只标该段,成功的照常进生成。
const results = await Promise.allSettled(
targets.map((segment) =>
api.submitVideo(pipelineProject.id, {
video_segment_id: segment.id,
prompt: `${prompt}${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds}`,
})
)
);
const failed = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
if (failed.length) {
// 部分失败也要刷新,让已成功提交的段立刻进「生成中」(否则 action 走 catch 分支会跳过刷新)
await refreshProjectDetail();
const firstErr = failed[0].reason;
const detail = firstErr instanceof Error ? firstErr.message : "提交失败";
throw new Error(`${targets.length - failed.length}/${targets.length} 段已提交,${failed.length} 段失败:${detail}(可对失败的段单独点重跑)`);
}
return targets.length;
}, "多段视频已提交,生成中…")
+64
View File
@@ -603,6 +603,36 @@ export function PipelinePage(props: {
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
const [triPanelOpen, setTriPanelOpen] = useState(false);
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
// 问题5(a):用「已有素材」直接当最终三视图——选商品已有图 / 上传本地图,attach 成商品组采用版(不走平台 AI 生成、不计费)。
const [triPickOpen, setTriPickOpen] = useState(false);
const [triPickBusy, setTriPickBusy] = useState(false);
const triFileRef = useRef<HTMLInputElement | null>(null);
async function attachExistingTri(assetId: string) {
setTriPickBusy(true);
try {
await onAttachBaseAsset({ kind: "product" }, assetId); // 后端 attach-base-asset 按 kind=product 命中/建商品组并采用
setTriPickOpen(false);
} finally {
setTriPickBusy(false);
}
}
async function uploadTriFromFile(file: File) {
setTriPickBusy(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("asset_type", "image");
fd.append("category", "tri_view"); // 标三视图类(与生成的三视图同类,送审/展示口径一致)
fd.append("name", `${productName || "商品"}·三视图`);
const asset = await api.uploadAsset(fd); // 落 TOS + 建 team 资产,返回带 id
await onAttachBaseAsset({ kind: "product" }, asset.id);
setTriPickOpen(false);
} catch (e) {
onNotify?.("error", e instanceof Error && e.message ? e.message : "上传失败,请重试");
} finally {
setTriPickBusy(false);
}
}
function jumpAssetSection(kind: "product" | "person" | "scene") {
setAssetTab(kind);
if (typeof document !== "undefined") {
@@ -2818,6 +2848,8 @@ export function PipelinePage(props: {
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}
</button>
{/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */}
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triPickBusy} onClick={() => setTriPickOpen(true)}>使</button>
</div>
</div>
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
@@ -3936,6 +3968,38 @@ export function PipelinePage(props: {
</div>
);
})()}
{/* 问题5(a):用已有素材作三视图——选商品已有图 / 上传本地图,attach 成商品采用版(不走平台 AI 生成、不计费) */}
{triPickOpen && (
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label="使用已有素材作三视图" onClick={(e) => { if (e.target === e.currentTarget && !triPickBusy) setTriPickOpen(false); }}>
<div className="asset-modal">
<div className="asset-modal-h">
<h2>使</h2>
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>// 选商品图或上传本地图,直接作最终三视图</span>
<button className="x" type="button" aria-label="关闭" disabled={triPickBusy} onClick={() => setTriPickOpen(false)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
<div className="asset-modal-body">
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriFromFile(f); e.target.value = ""; }} />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: "12px" }}>
{/* 上传本地图 tile */}
<div className="placeholder" role="button" tabIndex={0} aria-disabled={triPickBusy} title="上传本地图片作三视图" style={{ aspectRatio: "16/9", display: "grid", placeItems: "center", cursor: triPickBusy ? "default" : "pointer" }} onClick={() => { if (!triPickBusy) triFileRef.current?.click(); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !triPickBusy) { e.preventDefault(); triFileRef.current?.click(); } }}>
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>{triPickBusy ? "处理中…" : "+ 上传本地图"}</span>
</div>
{/* 商品已有图:点击直接采用为三视图 */}
{(productRecord?.images ?? []).map((img) => (
<div key={img.id} className={`placeholder${img.preview_url ? " has-mock-media is-zoomable" : ""}`} role="button" tabIndex={0} title="点击采用为三视图" aria-disabled={triPickBusy} style={{ aspectRatio: "16/9", cursor: triPickBusy ? "default" : "pointer", ...(img.preview_url ? mediaStyle(img.preview_url) : {}) }} onClick={() => { if (!triPickBusy) void attachExistingTri(img.asset); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !triPickBusy) { e.preventDefault(); void attachExistingTri(img.asset); } }}>
{!img.preview_url && <span className="mono" style={{ fontSize: "11px", color: "var(--black-alpha-48)" }}>// 无预览</span>}
</div>
))}
</div>
{(productRecord?.images ?? []).length === 0 && (
<div className="mono" style={{ marginTop: "10px", fontSize: "12px", color: "var(--black-alpha-48)" }}>// 该商品暂无已有图,可直接上传本地图</div>
)}
</div>
</div>
</div>
)}
{/* 流程步骤4 · 演员库:浏览 / 添加演员(AI生成·本地上传)/ 替换回填 */}
<ActorLibrary
open={Boolean(actorLib)}