diff --git a/core/backend/Dockerfile b/core/backend/Dockerfile index bb99c8a..64dc243 100644 --- a/core/backend/Dockerfile +++ b/core/backend/Dockerfile @@ -32,6 +32,8 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh EXPOSE 8000 ENTRYPOINT ["docker-entrypoint.sh"] -# timeout 300:前端已把多图生成拆成「每张一条请求」,单张约 30s;300s 给慢 ARK 留充足余量, -# 不再因一条请求串行出多张而超时(旧 120s 下生成 4 张必被 worker 强杀 → 500)。 -CMD ["gunicorn", "airshelf.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "300"] +# timeout 300:图片生成是慢请求(等 ARK+传 TOS ~30s),300s 给慢 ARK 留充足余量。 +# --threads 4:关键!纯 sync worker 下,几张图就能把 3 个 worker 占满,健康探针 /api/health/ +# 拿不到 worker → 超时 → pod 被判不就绪 → 流量被掐 → 整站 502(表现:报错但后端在干活、刷新能看到图)。 +# 图片生成是 I/O 密集(等 ARK/TOS 时线程让出 GIL),3 worker × 4 线程 = 12 并发,慢请求不再饿死探针。 +CMD ["gunicorn", "airshelf.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--threads", "4", "--timeout", "300"] diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 6b0049f..2b3e133 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -355,17 +355,28 @@ export function App() { } function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) { - // 每张图一条独立 HTTP 请求、并行发出(不再让一条请求串行出 N 张)—— - // 单张约 30s 不会触发后端请求超时;并行后 N 张总耗时≈单张,也不长时间占用后端进程。 + // 每张图一条独立 HTTP 请求(不再让一条请求串行出 N 张)——单张约 30s 不会触发后端请求超时。 + // 但不全量并行:同时最多 CONCURRENCY 条,避免 N 条一起砸向单个后端 pod 致内存峰值翻倍 OOM→502。 // 任一张失败只丢那一张(各自独立扣费/回滚),其余正常返回。 const n = Math.max(1, Math.min(Number(payload.count) || 1, 12)); + const CONCURRENCY = 2; return action(async () => { - const settled = await Promise.allSettled( - Array.from({ length: n }, () => api.generateImage({ prompt: payload.prompt, mode: payload.mode, count: 1 })) - ); - const assets = settled.flatMap((r) => (r.status === "fulfilled" ? r.value.assets : [])); + const results: Array<{ ok: true; assets: Asset[] } | { ok: false; reason: unknown }> = new Array(n); + let next = 0; + async function worker() { + for (let i = next++; i < n; i = next++) { + try { + const res = await api.generateImage({ prompt: payload.prompt, mode: payload.mode, count: 1 }); + results[i] = { ok: true, assets: res.assets }; + } catch (reason) { + results[i] = { ok: false, reason }; + } + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, n) }, worker)); + const assets = results.flatMap((r) => (r.ok ? r.assets : [])); if (assets.length === 0) { - const firstErr = settled.find((r) => r.status === "rejected") as PromiseRejectedResult | undefined; + const firstErr = results.find((r) => !r.ok) as { ok: false; reason: unknown } | undefined; throw new Error(firstErr ? String((firstErr.reason as Error)?.message || firstErr.reason || "生成失败") : "未生成任何图片"); } return { assets }; diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 5d9fbf9..0cb0c0c 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -490,6 +490,8 @@ export function ImageWorkbenchPage({ const [gridSort, setGridSort] = useState<"recent" | "name">("recent"); const [tbOpen, setTbOpen] = useState<"" | "time" | "model">(""); const [searchOpen, setSearchOpen] = useState(false); + // 模特网格默认只露 6 个;「全部模特 →」展开后露全部,再点收起(超过 6 个时第 7 个之后本来选不到) + const [showAllModels, setShowAllModels] = useState(false); function pickReference(event: ChangeEvent) { const file = event.target.files?.[0]; if (!file) return; @@ -834,14 +836,16 @@ export function ImageWorkbenchPage({
1 选择模特 - 全部模特 → + {personAssets.length > 6 && ( + setShowAllModels((v) => !v)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowAllModels((v) => !v); } }}>{showAllModels ? "收起 ↑" : `全部模特 (${personAssets.length}) →`} + )}
{personAssets.length > 0 ? personAssets .filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase())) .sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : (b.created_at || "").localeCompare(a.created_at || ""))) - .slice(0, 6) + .slice(0, showAllModels ? undefined : 6) .map((item) => { const url = item.files?.[0]?.preview_url; return (