From 6ba5d9606dd8082a063586bb05d8e6276db00b5e Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 3 Nov 2026 13:08:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A3=8E=E6=A0=BC=E5=8C=96?= =?UTF-8?q?=E5=A4=9A=E5=BC=A0=E7=94=9F=E6=88=90=E5=92=8C=E7=BB=93=E6=9D=9F?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 风格化请求 6 张图片时,98K 接入仍提交 n=1 且仅保留第一张结果,页面继续等待剩余图片。现在传递实际张数、保留全部返回结果,请求结束数量不足时结束等待并提示;刷新后也能从历史结果恢复结束状态。阿里通道参考图改为并行准备。 验证:46 项针对性测试通过,包括实际 Provider 流程提交一次并交付 6 张;TypeScript 检查通过。 --- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 19 ++++- lib/agents/scene-run-recovery.ts | 15 ++++ lib/ai/providers/alibaba-image.ts | 11 ++- lib/ai/providers/vip98k.ts | 7 +- tests/routes/vip98k-reference-routing.test.ts | 71 +++++++++++++++++++ 5 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 lib/agents/scene-run-recovery.ts diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 9ea0a2d7c..152997579 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -2,6 +2,7 @@ import { useChat } from "@ai-sdk/react"; import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery"; +import { getSceneRunResult } from "@/lib/agents/scene-run-recovery"; import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch"; import { DefaultChatTransport } from "ai"; import { @@ -4169,6 +4170,7 @@ export function AgentGenerationWorkspaceDarkV2({ ? "agent-anchor-materials" : "agent-anchor-generate"; + const [finishedSceneRequestId, setFinishedSceneRequestId] = useState(null); const { messages, setMessages, @@ -4189,7 +4191,11 @@ export function AgentGenerationWorkspaceDarkV2({ void refreshHistory(); } }, - onFinish() { void refreshHistory(); }, + onFinish({ messages: finishedMessages }) { + const lastUser = finishedMessages.filter((message) => message.role === "user").at(-1); + setFinishedSceneRequestId(lastUser?.id || null); + void refreshHistory(); + }, experimental_throttle: 100, generateId: generateUUID, transport: new DefaultChatTransport({ @@ -5482,6 +5488,17 @@ export function AgentGenerationWorkspaceDarkV2({ setActiveResultModeType(currentResultModeType); }, [currentResultModeType]); + useEffect(() => { + if (!pendingGeneration?.requestMessageId || pendingGeneration.modeType !== "scene_generation" || transportIsGenerating) return; + const result = getSceneRunResult(messages, pendingGeneration.requestMessageId); + const persisted = getSceneRunResult(historyMessages, pendingGeneration.requestMessageId); + if (!result || (finishedSceneRequestId !== pendingGeneration.requestMessageId && !persisted?.count)) return; + setPendingGeneration(null); + if (result.count < pendingGeneration.expectedCount) { + toast.warning(`本次生成已结束,收到 ${result.count}/${pendingGeneration.expectedCount} 张图片。缺少的图片未继续生成,可调整张数后重新提交。`); + } + }, [pendingGeneration, transportIsGenerating, finishedSceneRequestId, messages, historyMessages]); + useEffect(() => { if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return; // The backend has settled every requested card; an open SSE connection is no longer work. diff --git a/lib/agents/scene-run-recovery.ts b/lib/agents/scene-run-recovery.ts new file mode 100644 index 000000000..b03b330cc --- /dev/null +++ b/lib/agents/scene-run-recovery.ts @@ -0,0 +1,15 @@ +import { extractImageUrlsFromText } from "@/lib/image-url"; +import { messageText } from "./replacement-run-recovery"; + +type Message = { id: string; role: string; parts?: unknown[] }; + +export function getSceneRunResult(messages: Message[], requestMessageId: string) { + const index = messages.findIndex((message) => message.id === requestMessageId && message.role === "user"); + if (index < 0) return null; + const replies = messages.slice(index + 1); + // A later user turn belongs to another run. + if (replies.some((message) => message.role === "user")) return null; + const urls = new Set(replies.filter((message) => message.role === "assistant") + .flatMap((message) => extractImageUrlsFromText(messageText(message)))); + return { count: urls.size }; +} diff --git a/lib/ai/providers/alibaba-image.ts b/lib/ai/providers/alibaba-image.ts index 505e253d8..d173d28fa 100644 --- a/lib/ai/providers/alibaba-image.ts +++ b/lib/ai/providers/alibaba-image.ts @@ -769,16 +769,15 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) { } const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3); - const preparedImageUrls: string[] = []; - for (const imageUrl of selectedImageUrls) { - preparedImageUrls.push( - await prepareAlibabaInputImageUrl( + const preparedImageUrls = await Promise.all( + selectedImageUrls.map((imageUrl) => + prepareAlibabaInputImageUrl( imageUrl, activeModelId, options.imageParams?.inputImageRoleByUrl?.[imageUrl], ), - ); - } + ), + ); // qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。 if ( diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index 6f6c0a543..2f085c675 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -1438,7 +1438,7 @@ function normalizeDuration(value: unknown) { return Math.round(numeric); } -function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) { +export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) { const videoParams = params.videoParams || {}; const imageParams = params.imageParams || {}; const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80); @@ -1448,6 +1448,9 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | body.displayName = promptTitle || params.modelId; } if ("prompt" in body || !invocation) body.prompt = params.prompt; + if (params.kind === "image" && invocationSupportsBodyField(invocation, "n")) { + body.n = imageParams.imageCount ?? imageParams.n ?? 1; + } const aspectRatio = normalizeRatio(videoParams.ratio) || @@ -1697,7 +1700,7 @@ async function runTask(params: Vip98kTaskParams) { } const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K"; const archiveStartedAt = Date.now(); - const archived = await Promise.all(outputUrls.slice(0, 1).map(archiveOutputImage)); + const archived = await Promise.all(outputUrls.map(archiveOutputImage)); logger.info("result archive completed", { modelId: params.modelId, outputCount: archived.length, diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 75a019556..f36076c54 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -1,5 +1,10 @@ import { mergeGenerationHistory, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery"; import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { runInNewContext } from "node:vm"; +import ts from "typescript"; import sharp from "sharp"; import { isVip98kReferenceSizeAllowed, @@ -34,6 +39,9 @@ import { resolveVip98kSubmissionTimeoutMs, } from "@/lib/ai/vip98k-runtime-config"; import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models"; +import { buildTaskBody } from "@/lib/ai/providers/vip98k"; +import { getSceneRunResult } from "@/lib/agents/scene-run-recovery"; +import { extractImageUrlsFromText } from "@/lib/image-url"; const textToImageInvocation: Vip98kTaskInvocation = { kind: "tool", @@ -46,6 +54,69 @@ const textToImageInvocation: Vip98kTaskInvocation = { "/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Ftext-to-image&taskId={task_id}", }; +test("forwards six requested outputs independently of reference count", () => { + const invocation = selectVip98kTaskInvocation({ modelId: "gpt-image-2.5", configuredInvocation: null, imageCount: 2 }); + const { body } = buildTaskBody({ + apiKey: "test", modelId: "gpt-image-2.5", kind: "image", prompt: "Style the desk", + imageUrls: ["https://assets.test/product.png", "https://assets.test/model.png"], + videoUrls: [], modelConfig: {}, imageParams: { imageCount: 6, imageSize: "2K", aspectRatio: "1:1" }, + }, invocation); + expect(body.n).toBe(6); + expect(body.image).toHaveLength(2); + expect(body.size).toBe("2048x2048"); +}); + +test("does not inject output count into a task contract without n", () => { + const { body } = buildTaskBody({ + apiKey: "test", modelId: "nano-banana-2.1", kind: "image", prompt: "Desk", + imageUrls: [], videoUrls: [], modelConfig: {}, imageParams: { imageCount: 6 }, + }, textToImageInvocation); + expect(body).not.toHaveProperty("n"); +}); + +test("scene completion counts the current run and preserves partial results", () => { + const user = { id: "run", role: "user", parts: [] }; + const result = (count: number) => ({ id: "result", role: "assistant", parts: [{ type: "text", text: + Array.from({ length: count }, (_, i) => `![thumb|||https://assets.test/${i}.png|||2K](https://assets.test/${i}.png)`).join("\n\n") }] }); + expect(getSceneRunResult([user, result(6)], "run")).toEqual({ count: 6 }); + expect(getSceneRunResult([user, result(1)], "run")).toEqual({ count: 1 }); + expect(getSceneRunResult([user, result(0)], "run")).toEqual({ count: 0 }); + expect(getSceneRunResult([result(6), user], "run")).toEqual({ count: 0 }); + expect(getSceneRunResult([user, result(6), { ...user, id: "next" }], "run")).toBeNull(); + expect(getSceneRunResult([user, result(6)], "other")).toBeNull(); +}); + +test("provider generation retains all six outputs and submits only once", async () => { + const filename = resolve("lib/ai/providers/vip98k.ts"); + const localRequire = createRequire(filename); + const module = { exports: {} as any }; + const archived: string[] = []; + const requests: any[] = []; + const code = ts.transpileModule(readFileSync(filename, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }, + }).outputText; + runInNewContext(code, { + module, exports: module.exports, Buffer, URL, AbortSignal, setTimeout, clearTimeout, process, console, + require(id: string) { + if (id === "@/lib/db/queries") return { getSystemSetting: async () => null }; + if (id === "@/lib/storage/image-archive") return { + createDeferredUploadedImage: async (url: string) => { archived.push(url); return { original: url, thumbnail: url }; }, + }; + return localRequire(id.startsWith("@/") ? resolve(id.slice(2)) : id); + }, + fetch: async (_url: string, init: RequestInit) => { + requests.push(JSON.parse(String(init.body))); + return Response.json({ data: Array.from({ length: 6 }, (_, i) => ({ url: `https://assets.test/result-${i}.png` })) }); + }, + }); + const model = module.exports.createVip98k({ apiKey: "test", baseURL: "https://provider.test" }).chat("gpt-image-2.5"); + const result = await model.doGenerate({ messages: [{ role: "user", content: "Style a desk" }], imageParams: { imageCount: 6 } }); + expect(requests).toHaveLength(1); + expect(requests[0].n).toBe(6); + expect(archived).toHaveLength(6); + expect(extractImageUrlsFromText(result.text)).toHaveLength(6); +}); + test.describe("VIP98K reference-image routing", () => { test("keeps the provider-selected relay route header", () => { const [preset] = buildVip98kModelPresets([